Greasy Fork is available in English.
将选取范围/文章/整页复制为 Markdown,完整保留 KaTeX/MathJax/MathML 数学公式。增强 AI 聊天平台的代码区块语言检测。独立运作,相容 Trusted Types。
// ==UserScript==
// @name Copy MD + LaTeX
// @name:zh-TW 複製為 Markdown + LaTeX
// @name:zh-CN 复制为 Markdown + LaTeX
// @namespace mdltx.copy.self
// @version 3.2.4
// @description Copy selection/article/page as Markdown, preserving LaTeX from KaTeX/MathJax/MathML. Enhanced code block language detection for AI chat platforms. Self-contained with modern UI.
// @description:zh-TW 將選取範圍/文章/整頁複製為 Markdown,完整保留 KaTeX/MathJax/MathML 數學公式。增強 AI 聊天平台的程式碼區塊語言偵測。獨立運作,相容 Trusted Types。
// @description:zh-CN 将选取范围/文章/整页复制为 Markdown,完整保留 KaTeX/MathJax/MathML 数学公式。增强 AI 聊天平台的代码区块语言检测。独立运作,相容 Trusted Types。
// @license CC0-1.0
// @match *://*/*
// @match file:///*
// @run-at document-idle
// @noframes
// @grant unsafeWindow
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(() => {
'use strict';
// ─────────────────────────────────────────────────────────────
// § 設定系統
// ─────────────────────────────────────────────────────────────
const DEFAULTS = {
hotkeyEnabled: true, hotkeyAlt: true, hotkeyCtrl: false, hotkeyShift: false, hotkeyKey: 'm',
showButton: true, buttonPosition: 'bottom-right', buttonOffsetX: 16, buttonOffsetY: 16,
buttonOpacity: 0.85, buttonHoverOpacity: 1, buttonSize: 42,
buttonAutoHide: false, buttonAutoHideDelay: 1500, buttonHiddenOpacity: 0,
buttonClickAction: 'auto',
noSelectionMode: 'page', stripCommonIndentInBlockMath: true, absoluteUrls: true, waitMathJax: true, escapeMarkdownChars: true,
listMarker: '-', emphasisMarker: '*', strongMarker: '**', horizontalRule: '---',
articleMinChars: 600, articleMinRatio: 0.55, ignoreNav: false,
visibilityMode: 'loose', hiddenScanMaxElements: 5000, hiddenUntilFoundVisible: true, strictOffscreen: false, offscreenMargin: 100,
extractShadowDOM: true, extractIframes: false,
waitBeforeCaptureMs: 0, waitDomIdleMs: 0,
strongEmBlockStrategy: 'split', complexTableStrategy: 'list', detailsStrategy: 'preserve', unknownEmptyTagStrategy: 'literal', mergeAdjacentCodeSpans: true,
enableContentBasedLangDetection: true, lmArenaEnhancedDetection: true, aiChatPlatformDetection: true,
theme: 'auto', toastDuration: 2500, language: 'auto',
settingsMode: 'simple',
downloadFilenameTemplate: '{title}_{date}',
diagnosticLogging: false,
// ═══ Frontmatter 設定 ═══
downloadFrontmatter: false,
frontmatterTitle: true,
frontmatterDate: true,
frontmatterUrl: true,
frontmatterDescription: false,
frontmatterAuthor: false,
frontmatterTags: false,
frontmatterCustom: '',
// ═══ 元素選取模式設定 ═══
elementPickerEnabled: true,
elementPickerHotkey: 'e',
buttonDoubleClickAction: 'none',
// ═══ 預覽編輯設定 ═══
previewEnabled: true,
previewHotkey: 'p',
previewDefaultMode: 'preview',
previewMaxHeight: 70,
previewFontSize: 14,
previewAlwaysShow: false,
previewSplitView: false,
// ═══ 第三方腳本兼容性 ═══
thirdPartyCompatibility: true,
ignoreCollapsedCodeBlocks: true,
customExcludeSelectors: '',
customIgnoreHiddenSelectors: '',
settingsVersion: 7,
};
const SETTING_TYPES = {
hotkeyEnabled: 'boolean', hotkeyAlt: 'boolean', hotkeyCtrl: 'boolean', hotkeyShift: 'boolean', hotkeyKey: 'string',
showButton: 'boolean', buttonPosition: 'string', buttonOffsetX: 'number', buttonOffsetY: 'number',
buttonOpacity: 'number', buttonHoverOpacity: 'number', buttonSize: 'number',
buttonAutoHide: 'boolean', buttonAutoHideDelay: 'number', buttonHiddenOpacity: 'number',
buttonClickAction: 'string',
noSelectionMode: 'string', stripCommonIndentInBlockMath: 'boolean', absoluteUrls: 'boolean', waitMathJax: 'boolean', escapeMarkdownChars: 'boolean',
listMarker: 'string', emphasisMarker: 'string', strongMarker: 'string', horizontalRule: 'string',
articleMinChars: 'number', articleMinRatio: 'number', ignoreNav: 'boolean',
visibilityMode: 'string', hiddenScanMaxElements: 'number', hiddenUntilFoundVisible: 'boolean', strictOffscreen: 'boolean', offscreenMargin: 'number',
extractShadowDOM: 'boolean', extractIframes: 'boolean',
waitBeforeCaptureMs: 'number', waitDomIdleMs: 'number',
strongEmBlockStrategy: 'string', complexTableStrategy: 'string', detailsStrategy: 'string', unknownEmptyTagStrategy: 'string', mergeAdjacentCodeSpans: 'boolean',
enableContentBasedLangDetection: 'boolean', lmArenaEnhancedDetection: 'boolean', aiChatPlatformDetection: 'boolean',
theme: 'string', toastDuration: 'number', language: 'string',
settingsMode: 'string',
downloadFilenameTemplate: 'string',
diagnosticLogging: 'boolean',
// Frontmatter
downloadFrontmatter: 'boolean',
frontmatterTitle: 'boolean',
frontmatterDate: 'boolean',
frontmatterUrl: 'boolean',
frontmatterDescription: 'boolean',
frontmatterAuthor: 'boolean',
frontmatterTags: 'boolean',
frontmatterCustom: 'string',
// Element Picker
elementPickerEnabled: 'boolean',
elementPickerHotkey: 'string',
buttonDoubleClickAction: 'string',
// Preview
previewEnabled: 'boolean',
previewHotkey: 'string',
previewDefaultMode: 'string',
previewMaxHeight: 'number',
previewFontSize: 'number',
previewAlwaysShow: 'boolean',
previewSplitView: 'boolean',
// Third-party compatibility
thirdPartyCompatibility: 'boolean',
ignoreCollapsedCodeBlocks: 'boolean',
customExcludeSelectors: 'string',
customIgnoreHiddenSelectors: 'string',
settingsVersion: 'number',
};
const S = {
get(k) {
try {
const raw = GM_getValue(k, DEFAULTS[k]), type = SETTING_TYPES[k], def = DEFAULTS[k];
if (type === 'boolean') return raw === true || raw === 'true' || raw === 1 || raw === '1' ? true : raw === false || raw === 'false' || raw === 0 || raw === '0' ? false : def;
if (type === 'number') { const n = Number(raw); return isNaN(n) ? def : n; }
if (type === 'string') return raw == null ? def : String(raw);
return raw ?? def;
} catch (e) { console.warn('[mdltx] Failed to get setting:', k, e); return DEFAULTS[k]; }
},
set(k, v) { try { GM_setValue(k, v); } catch (e) { console.warn('[mdltx] Failed to set setting:', k, e); } },
getAll() { const r = {}; for (const k of Object.keys(DEFAULTS)) r[k] = this.get(k); return r; },
resetAll() { for (const k of Object.keys(DEFAULTS)) try { GM_setValue(k, DEFAULTS[k]); } catch (e) { console.warn('[mdltx] Failed to reset setting:', k, e); } }
};
function migrateSettings() {
try {
const cur = S.get('settingsVersion');
const migrations = [
[2, ['strongEmBlockStrategy', 'complexTableStrategy', 'detailsStrategy', 'unknownEmptyTagStrategy', 'hiddenUntilFoundVisible', 'strictOffscreen']],
[3, ['waitBeforeCaptureMs', 'waitDomIdleMs', 'mergeAdjacentCodeSpans', 'offscreenMargin']],
[4, ['buttonHoverOpacity', 'buttonSize', 'buttonAutoHide', 'buttonAutoHideDelay', 'buttonClickAction', 'listMarker', 'emphasisMarker', 'strongMarker', 'horizontalRule', 'settingsMode', 'buttonHiddenOpacity']],
[5, ['enableContentBasedLangDetection', 'lmArenaEnhancedDetection', 'aiChatPlatformDetection']],
[6, ['downloadFrontmatter', 'frontmatterTitle', 'frontmatterDate', 'frontmatterUrl', 'frontmatterDescription', 'frontmatterAuthor', 'frontmatterTags', 'frontmatterCustom', 'elementPickerEnabled', 'elementPickerHotkey', 'buttonDoubleClickAction', 'previewEnabled', 'previewHotkey', 'previewDefaultMode', 'previewMaxHeight', 'previewFontSize', 'thirdPartyCompatibility', 'ignoreCollapsedCodeBlocks', 'customExcludeSelectors', 'customIgnoreHiddenSelectors']],
[7, ['diagnosticLogging']],
];
for (const [ver, keys] of migrations) {
if (cur < ver) for (const k of keys) if (GM_getValue(k) === undefined) GM_setValue(k, DEFAULTS[k]);
}
if (cur < DEFAULTS.settingsVersion) GM_setValue('settingsVersion', DEFAULTS.settingsVersion);
} catch (e) { console.warn('[mdltx] Migration failed:', e); }
}
// ─────────────────────────────────────────────────────────────
// § 國際化
// ─────────────────────────────────────────────────────────────
const I18N = {
'zh-TW': {
copyMd: '複製 MD', copySelection: '複製選取內容', copyArticle: '智慧擷取文章', copyPage: '複製整個頁面', downloadMd: '下載為 .md 檔案', settings: '設定',
processing: '處理中...', copied: '已複製!', downloaded: '已下載!', failed: '失敗',
settingsTitle: 'MD+LaTeX 複製工具設定',
settingsModeLabel: '設定模式', settingsModeSimple: '簡易', settingsModeAdvanced: '進階',
generalSettings: '一般設定', showButton: '顯示浮動按鈕', buttonPosition: '按鈕位置', bottomRight: '右下角', bottomLeft: '左下角', topRight: '右上角', topLeft: '左上角',
buttonOpacity: '按鈕不透明度', buttonHoverOpacity: '懸停時不透明度', buttonSize: '按鈕大小',
buttonAutoHide: '自動隱藏按鈕', buttonAutoHideDelay: '離開後隱藏延遲 (ms)', buttonHiddenOpacity: '隱藏時不透明度',
buttonClickAction: '左鍵點擊動作', clickActionAuto: '自動(有選取複製選取,否則依預設)', clickActionSelection: '複製選取內容', clickActionArticle: '智慧擷取文章', clickActionPage: '複製整個頁面', clickActionDownload: '下載為 .md 檔案',
theme: '主題', themeAuto: '自動', themeLight: '淺色', themeDark: '深色', language: '語言', langAuto: '自動',
hotkeySettings: '快捷鍵設定', enableHotkey: '啟用快捷鍵', hotkeyCombo: '快捷鍵組合', pressKey: '按下按鍵...',
conversionSettings: '轉換設定', noSelectionMode: '無選取時預設模式', modePage: '整個頁面', modeArticle: '智慧文章', absoluteUrls: '使用絕對網址', ignoreNav: '忽略導覽/頁首/頁尾/側邊欄', waitMathJax: '等待 MathJax 渲染', stripIndent: '移除區塊數學的共同縮排', escapeMarkdownChars: '逸出 Markdown 特殊字元', extractShadowDOM: '擷取 Shadow DOM 內容', extractIframes: '擷取 iframe 內容(同源)',
markdownFormat: 'Markdown 格式', listMarker: '清單符號', emphasisMarker: '斜體符號', strongMarker: '粗體符號', horizontalRule: '水平線符號',
captureSettings: '擷取時機設定', waitBeforeCapture: '抽取前等待時間 (ms)', waitDomIdle: 'DOM 穩定後等待 (ms)',
visibilitySettings: '可見性設定', visibilityMode: '隱藏元素判斷策略', visibilityLoose: '寬鬆(display/visibility/hidden)', visibilityStrict: '嚴格(含 opacity/content-visibility/offscreen)', visibilityDom: 'DOM 優先(僅 hidden 屬性)', strictOffscreen: '啟用螢幕外元素偵測', offscreenMargin: '螢幕外邊界距離 (px)',
formatSettings: '格式處理設定', strongEmBlockStrategy: '粗體/斜體跨區塊策略', strategySplit: '拆段(推薦)', strategyHtml: 'HTML 標籤', strategyStrip: '移除格式', complexTableStrategy: '複雜表格策略', strategyList: '轉為清單', strategyTableHtml: 'HTML 表格', detailsStrategy: 'Details 元素策略', detailsPreserve: '保留完整內容', detailsStrictVisual: '僅保留 summary', mergeAdjacentCodeSpans: '合併相鄰程式碼區段',
codeBlockSettings: '程式碼區塊設定',
enableContentBasedLangDetection: '啟用內容推斷語言',
enableContentBasedLangDetectionTooltip: '根據程式碼內容特徵自動推斷語言',
lmArenaEnhancedDetection: 'LMArena 增強偵測',
lmArenaEnhancedDetectionTooltip: '針對 LMArena.ai 的程式碼區塊結構進行特殊處理',
aiChatPlatformDetection: 'AI 聊天平台增強偵測',
aiChatPlatformDetectionTooltip: '針對 Claude、Grok、ChatGPT 等平台的程式碼區塊進行特殊處理',
advancedSettings: '進階設定', articleMinChars: '文章最少字元數', articleMinRatio: '文章最小比例', toastDuration: 'Toast 顯示時間 (ms)',
diagnosticLogging: '啟用診斷紀錄',
diagnosticLoggingHint: '僅在需要偵錯時開啟,可能增加主控台輸出',
resetSettings: '重設為預設值', saveSettings: '儲存設定', cancel: '取消', close: '關閉',
toastSettingsSaved: '✅ 設定已儲存',
toastSettingsReset: '✅ 設定已重設',
toastGenericSuccess: '✅ 完成',
toastSuccess: '✅ 已複製 Markdown', toastSuccessDetail: '模式:{mode}|字元數:{count}', toastDownloadSuccess: '✅ 已下載 Markdown', toastDownloadDetail: '檔案:{filename}|字元數:{count}', toastError: '❌ 轉換失敗', toastErrorDetail: '錯誤:{error}',
modeSelection: '選取', modeArticleLabel: '文章', modePageLabel: '頁面',
hotkeyHint: '快捷鍵提示', dragToMove: '拖曳移動', currentHotkey: '目前快捷鍵', confirmReset: '確定要重設所有設定嗎?', settingsResetDone: '設定已重設為預設值', noSelection: '(無選取內容)', settingsSaved: '設定已儲存',
buttonHint: '左鍵:{action}\n右鍵:選單\n拖曳:移動',
buttonHintHotkey: '快捷鍵:{hotkey}',
settingsHint: 'Enter 儲存 · Esc 取消',
// 新增選單項目
pickElement: '選取元素',
previewCopy: '預覽後複製',
previewDownload: '預覽後下載',
// Frontmatter 設定
frontmatterSettings: 'Frontmatter 設定',
downloadFrontmatter: '下載時加入 Frontmatter',
frontmatterTitle: '標題',
frontmatterDate: '日期',
frontmatterUrl: '網址',
frontmatterDescription: '描述',
frontmatterAuthor: '作者',
frontmatterTags: '標籤',
frontmatterCustom: '自訂欄位',
frontmatterCustomHint: '每行一個,格式:key: value',
// 元素選取
elementPickerSettings: '元素選取設定',
elementPickerEnabled: '啟用元素選取功能',
elementPickerHotkey: '元素選取快捷鍵',
buttonDoubleClickAction: '按鈕雙擊動作',
doubleClickPicker: '進入元素選取',
doubleClickPreview: '預覽模式',
doubleClickNone: '無動作',
pickerModeActive: '元素選取模式',
pickerModeHint: '點擊選取元素,ESC 退出',
pickerCopied: '已複製選取元素',
modeElement: '元素',
// 預覽編輯
previewSettings: '預覽編輯設定',
previewEnabled: '啟用預覽編輯功能',
previewHotkey: '預覽快捷鍵',
previewDefaultMode: '預設模式',
previewModePreview: '預覽',
previewModeEdit: '編輯',
previewMaxHeight: '最大高度 (vh)',
previewFontSize: '字體大小',
previewTitle: 'Markdown 預覽',
previewCopyBtn: '複製',
previewDownloadBtn: '下載',
previewCopySuccess: '已複製到剪貼簿',
previewDownloadSuccess: '已下載檔案',
previewCharCount: '字元數',
previewLineCount: '行數',
previewWordCount: '字數',
previewEdit: '預覽編輯',
previewAlwaysShow: '複製/下載前總是預覽',
previewSplitView: '並列模式',
previewFullscreen: '全螢幕',
previewExitFullscreen: '退出全螢幕',
previewToolbar: '編輯工具',
toolBold: '粗體',
toolItalic: '斜體',
toolCode: '程式碼',
toolLink: '連結',
toolHeading: '標題',
toolList: '列表',
toolQuote: '引用',
toolHr: '分隔線',
pickerExit: '退出選取',
pickerExitHint: '點擊退出或按 ESC',
// 第三方兼容性
thirdPartySettings: '第三方腳本兼容性',
thirdPartyCompatibility: '啟用第三方腳本兼容模式',
thirdPartyCompatibilityTooltip: '自動處理其他油猴腳本可能造成的干擾',
ignoreCollapsedCodeBlocks: '忽略折疊的程式碼區塊',
ignoreCollapsedCodeBlocksTooltip: '抓取被 Collapsible Code Blocks 等腳本折疊的內容',
customExcludeSelectors: '自訂排除選擇器',
customExcludeSelectorsHint: '這些元素不會出現在輸出中',
customIgnoreHiddenSelectors: '自訂忽略隱藏選擇器',
customIgnoreHiddenSelectorsHint: '這些元素即使被隱藏也會被抓取',
thirdPartyDetected: '偵測到第三方腳本',
thirdPartyNone: '未偵測到已知的第三方腳本',
downloadSettings: '下載設定',
downloadFilenameTemplate: '檔名模板',
downloadFilenameHint: '可用變數:{title} {date} {time} {timestamp} {host} {path} {slug}',
hiddenScanMaxElements: '隱藏元素掃描上限',
hiddenUntilFoundVisible: '將 hidden="until-found" 視為可見',
unknownEmptyTagStrategy: '未知空標籤策略',
exportSettings: '匯出設定',
importSettings: '匯入設定',
exportSuccess: '設定已匯出到剪貼簿',
importSuccess: '設定已成功匯入',
importSuccessDetail: '已套用 {count} 項設定',
importIgnoredDetail: '已忽略 {count} 項不支援的設定',
importFailed: '匯入失敗:格式不正確',
importConfirm: '確定要匯入這些設定嗎?目前的設定會被覆蓋。',
detailsDefaultSummary: '詳細內容',
defaultDocumentName: '文件',
unsavedChangesWarning: '你有尚未儲存的編輯,確定要關閉嗎?',
discardChanges: '捨棄變更',
continueEditing: '繼續編輯',
renderEngineLabel: '預覽渲染引擎',
renderEngineBuiltin: '內建(輕巧)',
renderEngineEnhanced: '增強(更精準)',
renderEngineEnhancedHint: '支援更完整的 Markdown 語法',
cursorPosition: '行 {line}, 欄 {col}',
},
'zh-CN': {
copyMd: '复制 MD', copySelection: '复制选中内容', copyArticle: '智能提取文章', copyPage: '复制整个页面', downloadMd: '下载为 .md 文件', settings: '设置',
processing: '处理中...', copied: '已复制!', downloaded: '已下载!', failed: '失败',
settingsTitle: 'MD+LaTeX 复制工具设置',
settingsModeLabel: '设置模式', settingsModeSimple: '简易', settingsModeAdvanced: '高级',
generalSettings: '常规设置', showButton: '显示浮动按钮', buttonPosition: '按钮位置', bottomRight: '右下角', bottomLeft: '左下角', topRight: '右上角', topLeft: '左上角',
buttonOpacity: '按钮不透明度', buttonHoverOpacity: '悬停时不透明度', buttonSize: '按钮大小',
buttonAutoHide: '自动隐藏按钮', buttonAutoHideDelay: '离开后隐藏延迟 (ms)', buttonHiddenOpacity: '隐藏时不透明度',
buttonClickAction: '左键点击动作', clickActionAuto: '自动(有选中复制选中,否则依默认)', clickActionSelection: '复制选中内容', clickActionArticle: '智能提取文章', clickActionPage: '复制整个页面', clickActionDownload: '下载为 .md 文件',
theme: '主题', themeAuto: '自动', themeLight: '浅色', themeDark: '深色', language: '语言', langAuto: '自动',
hotkeySettings: '快捷键设置', enableHotkey: '启用快捷键', hotkeyCombo: '快捷键组合', pressKey: '按下按键...',
conversionSettings: '转换设置', noSelectionMode: '无选中时默认模式', modePage: '整个页面', modeArticle: '智能文章', absoluteUrls: '使用绝对网址', ignoreNav: '忽略导航/页眉/页脚/侧边栏', waitMathJax: '等待 MathJax 渲染', stripIndent: '移除块级数学的公共缩进', escapeMarkdownChars: '转义 Markdown 特殊字符', extractShadowDOM: '提取 Shadow DOM 内容', extractIframes: '提取 iframe 内容(同源)',
markdownFormat: 'Markdown 格式', listMarker: '列表符号', emphasisMarker: '斜体符号', strongMarker: '粗体符号', horizontalRule: '水平线符号',
captureSettings: '抓取时机设置', waitBeforeCapture: '抓取前等待时间 (ms)', waitDomIdle: 'DOM 稳定后等待 (ms)',
visibilitySettings: '可见性设置', visibilityMode: '隐藏元素判断策略', visibilityLoose: '宽松(display/visibility/hidden)', visibilityStrict: '严格(含 opacity/content-visibility/offscreen)', visibilityDom: 'DOM 优先(仅 hidden 属性)', strictOffscreen: '启用屏幕外元素检测', offscreenMargin: '屏幕外边界距离 (px)',
formatSettings: '格式处理设置', strongEmBlockStrategy: '粗体/斜体跨区块策略', strategySplit: '拆段(推荐)', strategyHtml: 'HTML 标签', strategyStrip: '移除格式', complexTableStrategy: '复杂表格策略', strategyList: '转为列表', strategyTableHtml: 'HTML 表格', detailsStrategy: 'Details 元素策略', detailsPreserve: '保留完整内容', detailsStrictVisual: '仅保留 summary', mergeAdjacentCodeSpans: '合并相邻代码区段',
codeBlockSettings: '代码区块设置',
enableContentBasedLangDetection: '启用内容推断语言',
enableContentBasedLangDetectionTooltip: '根据代码内容特征自动推断语言',
lmArenaEnhancedDetection: 'LMArena 增强检测',
lmArenaEnhancedDetectionTooltip: '针对 LMArena.ai 的代码区块结构进行特殊处理',
aiChatPlatformDetection: 'AI 聊天平台增强检测',
aiChatPlatformDetectionTooltip: '针对 Claude、Grok、ChatGPT 等平台的代码区块进行特殊处理',
advancedSettings: '高级设置', articleMinChars: '文章最少字符数', articleMinRatio: '文章最小比例', toastDuration: 'Toast 显示时间 (ms)',
diagnosticLogging: '启用诊断记录',
diagnosticLoggingHint: '仅在需要调试时开启,可能增加控制台输出',
resetSettings: '重置为默认值', saveSettings: '保存设置', cancel: '取消', close: '关闭',
toastSettingsSaved: '✅ 设置已保存',
toastSettingsReset: '✅ 设置已重置',
toastGenericSuccess: '✅ 完成',
toastSuccess: '✅ 已复制 Markdown', toastSuccessDetail: '模式:{mode}|字符数:{count}', toastDownloadSuccess: '✅ 已下载 Markdown', toastDownloadDetail: '文件:{filename}|字符数:{count}', toastError: '❌ 转换失败', toastErrorDetail: '错误:{error}',
modeSelection: '选中', modeArticleLabel: '文章', modePageLabel: '页面',
hotkeyHint: '快捷键提示', dragToMove: '拖拽移动', currentHotkey: '当前快捷键', confirmReset: '确定要重置所有设置吗?', settingsResetDone: '设置已重置为默认值', noSelection: '(无选中内容)', settingsSaved: '设置已保存',
buttonHint: '左键:{action}\n右键:菜单\n拖拽:移动',
buttonHintHotkey: '快捷键:{hotkey}',
settingsHint: 'Enter 保存 · Esc 取消',
pickElement: '选取元素',
previewCopy: '预览后复制',
previewDownload: '预览后下载',
frontmatterSettings: 'Frontmatter 设置',
downloadFrontmatter: '下载时加入 Frontmatter',
frontmatterTitle: '标题',
frontmatterDate: '日期',
frontmatterUrl: '网址',
frontmatterDescription: '描述',
frontmatterAuthor: '作者',
frontmatterTags: '标签',
frontmatterCustom: '自定义字段',
frontmatterCustomHint: '每行一个,格式:key: value',
elementPickerSettings: '元素选取设置',
elementPickerEnabled: '启用元素选取功能',
elementPickerHotkey: '元素选取快捷键',
buttonDoubleClickAction: '按钮双击动作',
doubleClickPicker: '进入元素选取',
doubleClickPreview: '预览模式',
doubleClickNone: '无动作',
pickerModeActive: '元素选取模式',
pickerModeHint: '点击选取元素,ESC 退出',
pickerCopied: '已复制选取元素',
modeElement: '元素',
previewSettings: '预览编辑设置',
previewEnabled: '启用预览编辑功能',
previewHotkey: '预览快捷键',
previewDefaultMode: '默认模式',
previewModePreview: '预览',
previewModeEdit: '编辑',
previewMaxHeight: '最大高度 (vh)',
previewFontSize: '字体大小',
previewTitle: 'Markdown 预览',
previewCopyBtn: '复制',
previewDownloadBtn: '下载',
previewCopySuccess: '已复制到剪贴板',
previewDownloadSuccess: '已下载文件',
previewCharCount: '字符数',
previewLineCount: '行数',
previewWordCount: '字数',
previewEdit: '预览编辑',
previewAlwaysShow: '复制/下载前总是预览',
previewSplitView: '并列模式',
previewFullscreen: '全屏',
previewExitFullscreen: '退出全屏',
previewToolbar: '编辑工具',
toolBold: '粗体',
toolItalic: '斜体',
toolCode: '代码',
toolLink: '链接',
toolHeading: '标题',
toolList: '列表',
toolQuote: '引用',
toolHr: '分隔线',
pickerExit: '退出选取',
pickerExitHint: '点击退出或按 ESC',
thirdPartySettings: '第三方脚本兼容性',
thirdPartyCompatibility: '启用第三方脚本兼容模式',
thirdPartyCompatibilityTooltip: '自动处理其他油猴脚本可能造成的干扰',
ignoreCollapsedCodeBlocks: '忽略折叠的代码区块',
ignoreCollapsedCodeBlocksTooltip: '抓取被 Collapsible Code Blocks 等脚本折叠的内容',
customExcludeSelectors: '自定义排除选择器',
customExcludeSelectorsHint: '这些元素不会出现在输出中',
customIgnoreHiddenSelectors: '自定义忽略隐藏选择器',
customIgnoreHiddenSelectorsHint: '这些元素即使被隐藏也会被抓取',
thirdPartyDetected: '检测到第三方脚本',
thirdPartyNone: '未检测到已知的第三方脚本',
downloadSettings: '下载设置',
downloadFilenameTemplate: '文件名模板',
downloadFilenameHint: '可用变量:{title} {date} {time} {timestamp} {host} {path} {slug}',
hiddenScanMaxElements: '隐藏元素扫描上限',
hiddenUntilFoundVisible: '将 hidden="until-found" 视为可见',
unknownEmptyTagStrategy: '未知空标签策略',
exportSettings: '导出设置',
importSettings: '导入设置',
exportSuccess: '设置已导出到剪贴板',
importSuccess: '设置已成功导入',
importSuccessDetail: '已应用 {count} 项设置',
importIgnoredDetail: '已忽略 {count} 项不支持的设置',
importFailed: '导入失败:格式不正确',
importConfirm: '确定要导入这些设置吗?当前的设置会被覆盖。',
detailsDefaultSummary: '详细内容',
defaultDocumentName: '文档',
unsavedChangesWarning: '你有尚未保存的编辑,确定要关闭吗?',
discardChanges: '放弃更改',
continueEditing: '继续编辑',
renderEngineLabel: '预览渲染引擎',
renderEngineBuiltin: '内建(轻巧)',
renderEngineEnhanced: '增强(更精准)',
renderEngineEnhancedHint: '支持更完整的 Markdown 语法',
cursorPosition: '行 {line}, 列 {col}',
},
'en': {
copyMd: 'Copy MD', copySelection: 'Copy Selection', copyArticle: 'Smart Article', copyPage: 'Copy Entire Page', downloadMd: 'Download as .md', settings: 'Settings',
processing: 'Processing...', copied: 'Copied!', downloaded: 'Downloaded!', failed: 'Failed',
settingsTitle: 'MD+LaTeX Copy Tool Settings',
settingsModeLabel: 'Settings Mode', settingsModeSimple: 'Simple', settingsModeAdvanced: 'Advanced',
generalSettings: 'General Settings', showButton: 'Show Floating Button', buttonPosition: 'Button Position', bottomRight: 'Bottom Right', bottomLeft: 'Bottom Left', topRight: 'Top Right', topLeft: 'Top Left',
buttonOpacity: 'Button Opacity', buttonHoverOpacity: 'Hover Opacity', buttonSize: 'Button Size',
buttonAutoHide: 'Auto-hide Button', buttonAutoHideDelay: 'Hide Delay After Leave (ms)', buttonHiddenOpacity: 'Hidden Opacity',
buttonClickAction: 'Left-click Action', clickActionAuto: 'Auto (copy selection if any, else default)', clickActionSelection: 'Copy Selection', clickActionArticle: 'Smart Article', clickActionPage: 'Copy Entire Page', clickActionDownload: 'Download as .md',
theme: 'Theme', themeAuto: 'Auto', themeLight: 'Light', themeDark: 'Dark', language: 'Language', langAuto: 'Auto',
hotkeySettings: 'Hotkey Settings', enableHotkey: 'Enable Hotkey', hotkeyCombo: 'Hotkey Combination', pressKey: 'Press a key...',
conversionSettings: 'Conversion Settings', noSelectionMode: 'Default Mode (No Selection)', modePage: 'Entire Page', modeArticle: 'Smart Article', absoluteUrls: 'Use Absolute URLs', ignoreNav: 'Ignore Nav/Header/Footer/Aside', waitMathJax: 'Wait for MathJax', stripIndent: 'Strip Common Indent in Block Math', escapeMarkdownChars: 'Escape Markdown special characters', extractShadowDOM: 'Extract Shadow DOM content', extractIframes: 'Extract iframe content (same-origin)',
markdownFormat: 'Markdown Format', listMarker: 'List Marker', emphasisMarker: 'Emphasis Marker', strongMarker: 'Strong Marker', horizontalRule: 'Horizontal Rule',
captureSettings: 'Capture Timing Settings', waitBeforeCapture: 'Wait before capture (ms)', waitDomIdle: 'Wait after DOM idle (ms)',
visibilitySettings: 'Visibility Settings', visibilityMode: 'Hidden Element Strategy', visibilityLoose: 'Loose (display/visibility/hidden)', visibilityStrict: 'Strict (incl. opacity/content-visibility/offscreen)', visibilityDom: 'DOM Only (hidden attribute only)', strictOffscreen: 'Enable offscreen element detection', offscreenMargin: 'Offscreen margin (px)',
formatSettings: 'Format Processing Settings', strongEmBlockStrategy: 'Bold/Italic Block Strategy', strategySplit: 'Split (recommended)', strategyHtml: 'HTML Tags', strategyStrip: 'Strip formatting', complexTableStrategy: 'Complex Table Strategy', strategyList: 'Convert to list', strategyTableHtml: 'HTML table', detailsStrategy: 'Details Element Strategy', detailsPreserve: 'Preserve full content', detailsStrictVisual: 'Keep summary only', mergeAdjacentCodeSpans: 'Merge adjacent code spans',
codeBlockSettings: 'Code Block Settings',
enableContentBasedLangDetection: 'Enable content-based language inference',
enableContentBasedLangDetectionTooltip: 'Automatically infer language from code content patterns',
lmArenaEnhancedDetection: 'LMArena enhanced detection',
lmArenaEnhancedDetectionTooltip: 'Special handling for LMArena.ai code block structures',
aiChatPlatformDetection: 'AI chat platform detection',
aiChatPlatformDetectionTooltip: 'Special handling for Claude, Grok, ChatGPT and other AI chat platforms',
advancedSettings: 'Advanced Settings', articleMinChars: 'Article Minimum Characters', articleMinRatio: 'Article Minimum Ratio', toastDuration: 'Toast Duration (ms)',
diagnosticLogging: 'Enable Diagnostic Logging',
diagnosticLoggingHint: 'Enable only for debugging; may increase console output',
resetSettings: 'Reset to Defaults', saveSettings: 'Save Settings', cancel: 'Cancel', close: 'Close',
toastSettingsSaved: '✅ Settings Saved',
toastSettingsReset: '✅ Settings Reset',
toastGenericSuccess: '✅ Done',
toastSuccess: '✅ Markdown Copied', toastSuccessDetail: 'Mode: {mode} | Characters: {count}', toastDownloadSuccess: '✅ Markdown Downloaded', toastDownloadDetail: 'File: {filename} | Characters: {count}', toastError: '❌ Conversion Failed', toastErrorDetail: 'Error: {error}',
modeSelection: 'Selection', modeArticleLabel: 'Article', modePageLabel: 'Page',
hotkeyHint: 'Hotkey Hint', dragToMove: 'Drag to move', currentHotkey: 'Current Hotkey', confirmReset: 'Are you sure you want to reset all settings?', settingsResetDone: 'Settings have been reset to defaults', noSelection: '(No selection)', settingsSaved: 'Settings saved',
buttonHint: 'Left: {action}\nRight: Menu\nDrag: Move',
buttonHintHotkey: 'Hotkey: {hotkey}',
settingsHint: 'Enter to Save · Esc to Cancel',
pickElement: 'Pick Element',
previewCopy: 'Preview & Copy',
previewDownload: 'Preview & Download',
frontmatterSettings: 'Frontmatter Settings',
downloadFrontmatter: 'Include Frontmatter in Download',
frontmatterTitle: 'Title',
frontmatterDate: 'Date',
frontmatterUrl: 'URL',
frontmatterDescription: 'Description',
frontmatterAuthor: 'Author',
frontmatterTags: 'Tags',
frontmatterCustom: 'Custom Fields',
frontmatterCustomHint: 'One per line, format: key: value',
elementPickerSettings: 'Element Picker Settings',
elementPickerEnabled: 'Enable Element Picker',
elementPickerHotkey: 'Element Picker Hotkey',
buttonDoubleClickAction: 'Button Double-click Action',
doubleClickPicker: 'Enter Element Picker',
doubleClickPreview: 'Preview Mode',
doubleClickNone: 'None',
pickerModeActive: 'Element Picker Mode',
pickerModeHint: 'Click to select, ESC to exit',
pickerCopied: 'Selected element copied',
modeElement: 'Element',
previewSettings: 'Preview & Edit Settings',
previewEnabled: 'Enable Preview & Edit',
previewHotkey: 'Preview Hotkey',
previewDefaultMode: 'Default Mode',
previewModePreview: 'Preview',
previewModeEdit: 'Edit',
previewMaxHeight: 'Max Height (vh)',
previewFontSize: 'Font Size',
previewTitle: 'Markdown Preview',
previewCopyBtn: 'Copy',
previewDownloadBtn: 'Download',
previewCopySuccess: 'Copied to clipboard',
previewDownloadSuccess: 'File downloaded',
previewCharCount: 'Characters',
previewLineCount: 'Lines',
previewWordCount: 'Words',
previewEdit: 'Preview & Edit',
previewAlwaysShow: 'Always preview before copy/download',
previewSplitView: 'Split View',
previewFullscreen: 'Fullscreen',
previewExitFullscreen: 'Exit Fullscreen',
previewToolbar: 'Edit Tools',
toolBold: 'Bold',
toolItalic: 'Italic',
toolCode: 'Code',
toolLink: 'Link',
toolHeading: 'Heading',
toolList: 'List',
toolQuote: 'Quote',
toolHr: 'Horizontal Rule',
pickerExit: 'Exit Picker',
pickerExitHint: 'Click to exit or press ESC',
thirdPartySettings: 'Third-Party Compatibility',
thirdPartyCompatibility: 'Enable third-party script compatibility',
thirdPartyCompatibilityTooltip: 'Handle interference from other userscripts',
ignoreCollapsedCodeBlocks: 'Ignore collapsed code blocks',
ignoreCollapsedCodeBlocksTooltip: 'Capture content collapsed by Collapsible Code Blocks',
customExcludeSelectors: 'Custom exclude selectors',
customExcludeSelectorsHint: 'These elements will not appear in output',
customIgnoreHiddenSelectors: 'Custom ignore-hidden selectors',
customIgnoreHiddenSelectorsHint: 'These elements will be captured even if hidden',
thirdPartyDetected: 'Third-party scripts detected',
thirdPartyNone: 'No known third-party scripts detected',
downloadSettings: 'Download Settings',
downloadFilenameTemplate: 'Filename Template',
downloadFilenameHint: 'Available variables: {title} {date} {time} {timestamp} {host} {path} {slug}',
hiddenScanMaxElements: 'Max hidden elements to scan',
hiddenUntilFoundVisible: 'Treat hidden="until-found" as visible',
unknownEmptyTagStrategy: 'Unknown empty tag strategy',
exportSettings: 'Export Settings',
importSettings: 'Import Settings',
exportSuccess: 'Settings exported to clipboard',
importSuccess: 'Settings imported successfully',
importSuccessDetail: '{count} settings applied',
importIgnoredDetail: '{count} unsupported settings ignored',
importFailed: 'Import failed: invalid format',
importConfirm: 'Import these settings? Current settings will be overwritten.',
detailsDefaultSummary: 'Details',
defaultDocumentName: 'document',
unsavedChangesWarning: 'You have unsaved edits. Close anyway?',
discardChanges: 'Discard',
continueEditing: 'Keep Editing',
renderEngineLabel: 'Preview Render Engine',
renderEngineBuiltin: 'Built-in (Lightweight)',
renderEngineEnhanced: 'Enhanced (More Accurate)',
renderEngineEnhancedHint: 'Supports more complete Markdown syntax',
cursorPosition: 'Ln {line}, Col {col}',
}
};
function detectLanguage() {
const lang = S.get('language');
if (lang !== 'auto') return lang;
const b = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
return /^zh-(tw|hk|mo|hant)/.test(b) ? 'zh-TW' : b.startsWith('zh') ? 'zh-CN' : 'en';
}
function t(key, r = {}) {
let text = I18N[detectLanguage()]?.[key] || I18N['en'][key] || key;
for (const [k, v] of Object.entries(r)) text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
return text;
}
function getEffectiveTheme() {
const theme = S.get('theme');
if (theme !== 'auto') return theme;
try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch { return 'light'; }
}
function getHotkeyString() {
if (!S.get('hotkeyEnabled')) return '';
const parts = [];
if (S.get('hotkeyCtrl')) parts.push('Ctrl');
if (S.get('hotkeyAlt')) parts.push('Alt');
if (S.get('hotkeyShift')) parts.push('Shift');
parts.push(S.get('hotkeyKey').toUpperCase());
return parts.join('+');
}
function diagLog(...args) {
if (!S.get('diagnosticLogging')) return;
try { console.debug('[mdltx]', ...args); } catch {}
}
function getClickActionLabel(short = false) {
const action = S.get('buttonClickAction'), lang = detectLanguage();
if (short) {
const map = {
'zh-TW': { auto: '自動', selection: '選取', article: '文章', page: '頁面', download: '下載' },
'zh-CN': { auto: '自动', selection: '选中', article: '文章', page: '页面', download: '下载' },
'en': { auto: 'Auto', selection: 'Selection', article: 'Article', page: 'Page', download: 'Download' }
};
return (map[lang] || map['en'])[action] || map[lang]?.auto || 'Auto';
}
return { auto: t('clickActionAuto'), selection: t('copySelection'), article: t('copyArticle'), page: t('copyPage'), download: t('downloadMd') }[action] || t('clickActionAuto');
}
// ─────────────────────────────────────────────────────────────
// § SVG 圖示
// ─────────────────────────────────────────────────────────────
const SVG_NS = 'http://www.w3.org/2000/svg';
const ICON_DEFS = {
markdown: {
viewBox: '0 0 24 24',
elements: [
{ type: 'rect', x: '7.3', y: '5.0', width: '14.2', height: '15.8', rx: '3.0', strokeOpacity: '0.28', strokeWidth: '1.65', strokeLinecap: 'round', strokeLinejoin: 'round' },
{ type: 'rect', x: '3.0', y: '3.2', width: '14.2', height: '15.8', rx: '3.0', fill: 'currentColor', fillOpacity: '0.08', strokeOpacity: '0.98', strokeWidth: '1.8', strokeLinecap: 'round', strokeLinejoin: 'round' },
{ type: 'path', d: 'M6.35 16.1V8.85l2.35 3.05 2.35-3.05v7.25', strokeOpacity: '0.95', strokeWidth: '2.0', strokeLinecap: 'round', strokeLinejoin: 'round' },
{ type: 'path', d: 'M15.05 8.9v6.1', strokeOpacity: '0.92', strokeWidth: '2.0', strokeLinecap: 'round', strokeLinejoin: 'round' },
{ type: 'path', d: 'M13.6 13.25l1.45 1.9 1.45-1.9', strokeOpacity: '0.92', strokeWidth: '2.0', strokeLinecap: 'round', strokeLinejoin: 'round' }
]
},
copy: 'M9 9h10v10H9zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1',
download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
selection: 'M4 7V4h3M20 7V4h-3M4 17v3h3M20 17v3h-3M9 9h6v6H9z',
article: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8',
page: 'M3 3h18v18H3zM3 9h18M9 21V9',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
check: 'M20 6L9 17l-5-5',
x: 'M18 6L6 18M6 6l12 12',
alertCircle: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 8v4M12 16h.01',
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01',
chevronDown: 'M6 9l6 6 6-6',
chevronUp: 'M18 15l-6-6-6 6',
// 元素選取
crosshair: 'M12 2v4M12 18v4M2 12h4M18 12h4M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
// 預覽編輯
eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z',
edit3: 'M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z',
fileText: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8',
maximize: 'M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3',
minimize: 'M4 14h6v6m10-10h-6V4m0 6l7-7M3 21l7-7',
columns: 'M12 3v18m9-18H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z',
bold: 'M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6zM6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z',
italic: 'M19 4h-9m4 16H5m9-16l-4 16',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
heading: 'M6 4v16M18 4v16M6 12h12',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
quote: 'M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21zm12 0c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z',
minus: 'M5 12h14',
xCircle: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM15 9l-6 6M9 9l6 6',
upload: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12',
clipboard: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z',
};
function createIcon(type, size) {
const def = ICON_DEFS[type];
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'icon');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('shape-rendering', 'geometricPrecision');
if (size) { svg.setAttribute('width', String(size)); svg.setAttribute('height', String(size)); }
if (typeof def === 'string') {
svg.setAttribute('viewBox', '0 0 24 24');
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', def);
svg.appendChild(path);
} else if (def && typeof def === 'object') {
svg.setAttribute('viewBox', def.viewBox || '0 0 24 24');
for (const el of (def.elements || [])) {
let node;
if (el.type === 'path') {
node = document.createElementNS(SVG_NS, 'path');
node.setAttribute('d', el.d);
} else if (el.type === 'rect') {
node = document.createElementNS(SVG_NS, 'rect');
['x', 'y', 'width', 'height', 'rx', 'ry'].forEach(a => el[a] && node.setAttribute(a, el[a]));
} else if (el.type === 'circle') {
node = document.createElementNS(SVG_NS, 'circle');
['cx', 'cy', 'r'].forEach(a => el[a] && node.setAttribute(a, el[a]));
}
if (node) {
['fill', 'stroke', 'fillOpacity', 'strokeOpacity', 'strokeWidth', 'strokeLinecap', 'strokeLinejoin'].forEach(a => {
if (el[a] != null) node.setAttribute(a.replace(/[A-Z]/g, m => '-' + m.toLowerCase()), String(el[a]));
});
svg.appendChild(node);
}
}
} else {
svg.setAttribute('viewBox', '0 0 24 24');
}
return svg;
}
// ─────────────────────────────────────────────────────────────
// § 樣式表
// ─────────────────────────────────────────────────────────────
const STYLES = `
:host{all:initial;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:14px;line-height:1.5}
.mdltx-root{--mdltx-primary:#2563eb;--mdltx-primary-hover:#1d4ed8;--mdltx-success:#16a34a;--mdltx-error:#dc2626;--mdltx-warning:#d97706;--mdltx-focus-ring:0 0 0 3px rgba(37,99,235,0.4);--mdltx-radius-sm:8px;--mdltx-radius-md:12px;--mdltx-radius-lg:16px;--mdltx-surface:rgba(255,255,255,0.7);--mdltx-border-subtle:rgba(0,0,0,0.06);--mdltx-overlay-blur:6px}
.mdltx-root[data-theme="light"]{--mdltx-bg:#fff;--mdltx-bg-secondary:#f3f4f6;--mdltx-bg-tertiary:#e5e7eb;--mdltx-bg-elevated:#fefefe;--mdltx-text:#1f2937;--mdltx-text-secondary:#6b7280;--mdltx-border:#d1d5db;--mdltx-border-subtle:rgba(0,0,0,0.08);--mdltx-shadow:rgba(15,23,42,0.12);--mdltx-shadow-lg:rgba(15,23,42,0.2);--mdltx-overlay:rgba(15,23,42,0.48)}
.mdltx-root[data-theme="dark"]{--mdltx-bg:#1f2937;--mdltx-bg-secondary:#374151;--mdltx-bg-tertiary:#4b5563;--mdltx-bg-elevated:#273244;--mdltx-text:#f9fafb;--mdltx-text-secondary:#9ca3af;--mdltx-border:#4b5563;--mdltx-border-subtle:rgba(255,255,255,0.08);--mdltx-shadow:rgba(0,0,0,0.35);--mdltx-shadow-lg:rgba(0,0,0,0.5);--mdltx-overlay:rgba(0,0,0,0.7)}
.mdltx-root{color:var(--mdltx-text)}.mdltx-root *{box-sizing:border-box;margin:0;padding:0}
@media(prefers-reduced-motion:reduce){.mdltx-root *,.mdltx-root *::before,.mdltx-root *::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;transition-duration:0.01ms!important}.mdltx-btn:hover:not(.dragging):not(.processing){transform:none!important}.mdltx-toast.show{transform:translateX(-50%) translateY(0)!important}.mdltx-menu.open{transform:scale(1) translateY(0)!important}.mdltx-modal-overlay.open .mdltx-modal,.mdltx-modal-overlay.open .mdltx-preview-modal{transform:scale(1)!important}}
.mdltx-root button:focus-visible,.mdltx-root .mdltx-menu-item:focus-visible,.mdltx-root .mdltx-select:focus-visible,.mdltx-root .mdltx-input:focus-visible,.mdltx-root .mdltx-checkbox:focus-visible,.mdltx-root .mdltx-range:focus-visible{outline:2px solid var(--mdltx-primary);outline-offset:2px}
.mdltx-btn{position:fixed;z-index:2147483647;display:flex;align-items:center;justify-content:center;width:var(--mdltx-btn-size,42px);height:var(--mdltx-btn-size,42px);padding:0;border-radius:50%;border:1px solid var(--mdltx-border);background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(255,255,255,0.75));color:var(--mdltx-text);box-shadow:0 6px 16px var(--mdltx-shadow);cursor:pointer;user-select:none;touch-action:none;transition:transform 0.2s ease,box-shadow 0.2s ease,background 0.2s ease,color 0.2s ease,opacity 0.3s ease;font-family:inherit;opacity:var(--mdltx-btn-opacity,0.85);will-change:transform,opacity}
.mdltx-root[data-theme="dark"] .mdltx-btn{background:linear-gradient(180deg,rgba(55,65,81,0.95),rgba(31,41,55,0.9))}
.mdltx-btn:hover:not(.dragging):not(.processing){transform:translateY(-2px) scale(1.05);box-shadow:0 10px 24px var(--mdltx-shadow-lg);opacity:var(--mdltx-btn-hover-opacity,1)!important}
.mdltx-btn:focus-visible{outline:3px solid var(--mdltx-primary);outline-offset:2px;box-shadow:0 0 0 4px rgba(37,99,235,0.18)}
.mdltx-btn:active:not(.dragging){transform:translateY(0) scale(0.98)}
.mdltx-btn.dragging{cursor:grabbing;opacity:0.9!important;transition:opacity 0.1s ease}
.mdltx-btn.processing{pointer-events:none}
.mdltx-btn.success{background:var(--mdltx-success);color:#fff;border-color:var(--mdltx-success)}
.mdltx-btn.error{background:var(--mdltx-error);color:#fff;border-color:var(--mdltx-error)}
.mdltx-btn.auto-hidden{opacity:var(--mdltx-btn-hidden-opacity,0)!important;pointer-events:none}
.mdltx-btn-icon{width:70%;height:70%;display:flex;align-items:center;justify-content:center}
.mdltx-btn-icon svg{width:100%;height:100%}
.mdltx-btn-spinner{width:50%;height:50%;border:2px solid var(--mdltx-border);border-top-color:var(--mdltx-primary);border-radius:50%;animation:mdltx-spin 0.8s linear infinite}
@keyframes mdltx-spin{to{transform:rotate(360deg)}}
.mdltx-sensor{position:fixed;z-index:2147483646;background:transparent;pointer-events:auto;border-radius:50%}
.mdltx-tooltip{position:fixed;z-index:2147483648;background:var(--mdltx-bg-elevated);color:var(--mdltx-text);border:1px solid var(--mdltx-border-subtle);padding:10px 14px;border-radius:12px;font-size:12px;line-height:1.5;box-shadow:0 8px 20px var(--mdltx-shadow-lg);max-width:260px;opacity:0;visibility:hidden;transition:opacity 0.15s ease,visibility 0.15s ease;pointer-events:none;white-space:pre-line}
.mdltx-tooltip.show{opacity:1;visibility:visible}
.mdltx-tooltip-hotkey{display:block;margin-top:6px;padding-top:6px;border-top:1px solid var(--mdltx-border);color:var(--mdltx-text-secondary);font-size:11px}
.mdltx-menu{position:fixed;z-index:2147483647;min-width:220px;padding:6px;background:var(--mdltx-bg-elevated);border:1px solid var(--mdltx-border-subtle);border-radius:14px;box-shadow:0 14px 32px var(--mdltx-shadow-lg);opacity:0;visibility:hidden;transform:scale(0.95) translateY(-10px);transform-origin:top;transition:opacity 0.2s cubic-bezier(0,0,0.2,1),visibility 0.2s cubic-bezier(0,0,0.2,1),transform 0.2s cubic-bezier(0,0,0.2,1)}
.mdltx-menu.open{opacity:1;visibility:visible;transform:scale(1) translateY(0)}
.mdltx-menu.from-bottom{transform-origin:bottom;transform:scale(0.95) translateY(10px)}
.mdltx-menu.from-bottom.open{transform:scale(1) translateY(0)}
.mdltx-menu-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;cursor:pointer;transition:background 0.15s ease,transform 0.15s ease;color:var(--mdltx-text);border:none;background:none;width:100%;text-align:left;font-family:inherit;font-size:14px}
.mdltx-menu-item:hover:not(:disabled){background:var(--mdltx-bg-secondary);transform:translateX(2px)}
.mdltx-menu-item:hover:not(:disabled) .mdltx-menu-item-icon{color:var(--mdltx-primary)}
.mdltx-menu-item:focus-visible{background:var(--mdltx-bg-secondary);outline:none}
.mdltx-menu-item:active:not(:disabled){background:var(--mdltx-bg-tertiary)}
.mdltx-menu-item:disabled{opacity:0.5;cursor:not-allowed}
.mdltx-menu-item-icon{width:18px;height:18px;flex-shrink:0;color:var(--mdltx-text-secondary);display:flex;align-items:center;justify-content:center}
.mdltx-menu-item-icon svg{width:100%;height:100%}
.mdltx-menu-item.active .mdltx-menu-item-icon{color:var(--mdltx-primary)}
.mdltx-menu-item-text{flex:1}
.mdltx-menu-item-hint{font-size:12px;color:var(--mdltx-text-secondary);margin-left:auto}
.mdltx-menu-divider{height:1px;background:var(--mdltx-border);margin:6px 0}
.mdltx-menu-hint{padding:6px 12px;font-size:11px;color:var(--mdltx-text-secondary)}
.mdltx-toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom,0px));transform:translateX(-50%) translateY(100px);z-index:2147483647;display:flex;align-items:flex-start;gap:12px;padding:14px 18px;min-width:280px;max-width:min(520px,90vw);border-radius:14px;background:var(--mdltx-bg-elevated);border:1px solid var(--mdltx-border-subtle);box-shadow:0 12px 32px var(--mdltx-shadow-lg);opacity:0;visibility:hidden;transition:all 0.3s cubic-bezier(0.4,0,0.2,1)}
.mdltx-toast.show{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0);transition:all 0.4s cubic-bezier(0.34,1.56,0.64,1)}
.mdltx-toast.success{border-left:4px solid var(--mdltx-success)}
.mdltx-toast.error{border-left:4px solid var(--mdltx-error)}
.mdltx-toast.info{border-left:4px solid var(--mdltx-primary)}
.mdltx-toast-icon{width:20px;height:20px;flex-shrink:0;margin-top:2px;display:flex;align-items:center;justify-content:center}
.mdltx-toast-icon svg{width:100%;height:100%}
.mdltx-toast.success .mdltx-toast-icon{color:var(--mdltx-success)}
.mdltx-toast.error .mdltx-toast-icon{color:var(--mdltx-error)}
.mdltx-toast.info .mdltx-toast-icon{color:var(--mdltx-primary)}
.mdltx-toast-content{flex:1;min-width:0}
.mdltx-toast-title{font-weight:600;margin-bottom:2px}
.mdltx-toast-detail{font-size:13px;color:var(--mdltx-text-secondary);word-break:break-word}
.mdltx-toast-close{width:24px;height:24px;padding:4px;border:none;background:none;cursor:pointer;border-radius:6px;color:var(--mdltx-text-secondary);transition:all 0.15s ease;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.mdltx-toast-close svg{width:16px;height:16px}
.mdltx-toast-close:hover{background:var(--mdltx-bg-secondary);color:var(--mdltx-text)}
.mdltx-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483647;background:var(--mdltx-overlay);display:flex;align-items:center;justify-content:center;padding:20px;opacity:0;visibility:hidden;transition:all 0.2s ease;-webkit-backdrop-filter:blur(var(--mdltx-overlay-blur));backdrop-filter:blur(var(--mdltx-overlay-blur))}
.mdltx-modal-overlay.open{opacity:1;visibility:visible}
.mdltx-modal{width:100%;max-width:660px;max-height:calc(100vh - 40px);background:var(--mdltx-bg);border-radius:var(--mdltx-radius-lg);box-shadow:0 24px 52px var(--mdltx-shadow-lg);display:flex;flex-direction:column;transform:scale(0.95);transition:transform 0.2s ease;border:1px solid var(--mdltx-border-subtle)}
.mdltx-modal-overlay.open .mdltx-modal{transform:scale(1)}
.mdltx-modal-header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px;border-bottom:1px solid var(--mdltx-border-subtle);flex-shrink:0;background:linear-gradient(180deg,rgba(255,255,255,0.7),rgba(255,255,255,0))}
.mdltx-root[data-theme="dark"] .mdltx-modal-header{background:linear-gradient(180deg,rgba(31,41,55,0.7),rgba(31,41,55,0))}
.mdltx-modal-title{font-size:18px;font-weight:600;color:var(--mdltx-text)}
.mdltx-modal-close{width:32px;height:32px;padding:6px;border:none;background:none;cursor:pointer;border-radius:10px;color:var(--mdltx-text-secondary);transition:all 0.15s ease;display:flex;align-items:center;justify-content:center}
.mdltx-modal-close svg{width:20px;height:20px}
.mdltx-modal-close:hover{background:var(--mdltx-bg-secondary);color:var(--mdltx-text)}
.mdltx-modal-body{flex:1;overflow-y:auto;padding:20px 24px;background:linear-gradient(var(--mdltx-bg) 33%,transparent) center top,linear-gradient(transparent,var(--mdltx-bg) 66%) center bottom,radial-gradient(farthest-side at 50% 0,rgba(0,0,0,0.08),transparent) center top,radial-gradient(farthest-side at 50% 100%,rgba(0,0,0,0.08),transparent) center bottom;background-repeat:no-repeat;background-size:100% 40px,100% 40px,100% 10px,100% 10px;background-attachment:local,local,scroll,scroll}
.mdltx-modal-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 24px;border-top:1px solid var(--mdltx-border-subtle);flex-shrink:0;background:linear-gradient(0deg,rgba(255,255,255,0.7),rgba(255,255,255,0))}
.mdltx-root[data-theme="dark"] .mdltx-modal-footer{background:linear-gradient(0deg,rgba(31,41,55,0.7),rgba(31,41,55,0))}
.mdltx-modal-footer-hint{font-size:12px;color:var(--mdltx-text-secondary)}
.mdltx-modal-footer-left,.mdltx-modal-footer-right{display:flex;gap:8px}
.mdltx-mode-toggle{display:flex;background:var(--mdltx-bg-secondary);border-radius:var(--mdltx-radius-md);padding:4px;margin-bottom:20px;border:1px solid var(--mdltx-border-subtle)}
.mdltx-mode-toggle-btn{flex:1;padding:8px 16px;border:none;background:none;color:var(--mdltx-text-secondary);font-size:13px;font-weight:600;cursor:pointer;border-radius:10px;transition:all 0.2s ease}
.mdltx-mode-toggle-btn:hover{color:var(--mdltx-text)}
.mdltx-mode-toggle-btn.active{background:var(--mdltx-bg-elevated);color:var(--mdltx-text);box-shadow:0 1px 3px var(--mdltx-shadow);transition:all 0.25s cubic-bezier(0.4,0,0.2,1)}
.mdltx-mode-toggle-btn:focus-visible{outline:2px solid var(--mdltx-primary);outline-offset:-2px}
.mdltx-section{margin-bottom:24px}
.mdltx-section:last-child{margin-bottom:0}
.mdltx-section.hidden{display:none}
.mdltx-section-title{font-size:12px;font-weight:700;color:var(--mdltx-text-secondary);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--mdltx-border-subtle);display:flex;align-items:center;gap:8px}
.mdltx-section-title::before{content:'';display:inline-block;width:3px;height:14px;background:var(--mdltx-primary);border-radius:2px;flex-shrink:0}
.mdltx-field{margin-bottom:16px}
.mdltx-field:last-child{margin-bottom:0}
.mdltx-field.hidden{display:none}
.mdltx-field-row{display:flex;align-items:center;justify-content:space-between;gap:16px}
.mdltx-field-hint{margin-top:6px;font-size:12px;color:var(--mdltx-text-secondary)}
.mdltx-label{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--mdltx-text);cursor:pointer}
.mdltx-label-text{flex:1}
.mdltx-checkbox{width:18px;height:18px;accent-color:var(--mdltx-primary);cursor:pointer}
.mdltx-select{padding:8px 12px;border:1px solid var(--mdltx-border);border-radius:var(--mdltx-radius-sm);background:var(--mdltx-bg-elevated);color:var(--mdltx-text);font-family:inherit;font-size:14px;cursor:pointer;min-width:180px;box-shadow:inset 0 1px 0 rgba(255,255,255,0.25)}
.mdltx-select:hover{border-color:var(--mdltx-border-subtle)}
.mdltx-select:focus{outline:none;border-color:var(--mdltx-primary);box-shadow:var(--mdltx-focus-ring)}
.mdltx-input{padding:8px 12px;border:1px solid var(--mdltx-border);border-radius:var(--mdltx-radius-sm);background:var(--mdltx-bg-elevated);color:var(--mdltx-text);font-family:inherit;font-size:14px;width:100px;transition:border-color 0.15s ease,box-shadow 0.15s ease}
.mdltx-input:focus{outline:none;border-color:var(--mdltx-primary);box-shadow:var(--mdltx-focus-ring)}
.mdltx-input.invalid{border-color:var(--mdltx-error);background:rgba(220,38,38,0.05)}
.mdltx-input.valid{border-color:var(--mdltx-success)}
.mdltx-input-wrapper{position:relative;display:inline-flex;align-items:center}
.mdltx-range-container{display:flex;align-items:center;gap:8px}
.mdltx-range{width:120px;accent-color:var(--mdltx-primary)}
.mdltx-range-value{font-size:13px;color:var(--mdltx-text-secondary);min-width:48px;text-align:right}
.mdltx-hotkey-input{display:flex;align-items:center;gap:8px}
.mdltx-hotkey-display{display:flex;gap:4px;flex-wrap:wrap}
.mdltx-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:26px;padding:0 8px;background:var(--mdltx-bg-secondary);border:1px solid var(--mdltx-border);border-radius:6px;font-size:12px;font-weight:500;color:var(--mdltx-text)}
.mdltx-hotkey-record-btn{padding:6px 12px;border:1px solid var(--mdltx-border);border-radius:8px;background:var(--mdltx-bg-secondary);color:var(--mdltx-text);font-family:inherit;font-size:13px;cursor:pointer;transition:all 0.15s ease}
.mdltx-hotkey-record-btn:hover{background:var(--mdltx-bg-tertiary)}
.mdltx-hotkey-record-btn.recording{background:var(--mdltx-primary);color:#fff;border-color:var(--mdltx-primary)}
.mdltx-btn-primary{padding:10px 20px;border:none;border-radius:var(--mdltx-radius-sm);background:linear-gradient(180deg,var(--mdltx-primary),var(--mdltx-primary-hover));color:#fff;font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.15s ease;box-shadow:0 6px 14px rgba(37,99,235,0.25)}
.mdltx-btn-primary:hover{transform:translateY(-1px);box-shadow:0 10px 18px rgba(37,99,235,0.28)}
.mdltx-btn-primary:active{transform:translateY(0)}
.mdltx-btn-primary:focus-visible{outline:2px solid var(--mdltx-primary);outline-offset:2px}
.mdltx-btn-secondary{padding:10px 20px;border:1px solid var(--mdltx-border);border-radius:var(--mdltx-radius-sm);background:var(--mdltx-bg-elevated);color:var(--mdltx-text);font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.15s ease}
.mdltx-btn-secondary:hover{background:var(--mdltx-bg-secondary);border-color:var(--mdltx-border-subtle)}
.mdltx-btn-secondary:focus-visible{outline:2px solid var(--mdltx-primary);outline-offset:2px}
.mdltx-btn-danger{padding:10px 20px;border:1px solid rgba(220,38,38,0.6);border-radius:var(--mdltx-radius-sm);background:transparent;color:var(--mdltx-error);font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.15s ease}
.mdltx-btn-danger:hover{background:var(--mdltx-error);color:#fff}
.mdltx-btn-danger:focus-visible{outline:2px solid var(--mdltx-error);outline-offset:2px}
.icon{display:inline-block;vertical-align:middle}
.mdltx-conditional{margin-left:26px;padding-left:12px;border-left:2px solid var(--mdltx-border);margin-top:8px}
.mdltx-conditional.hidden{display:none}
/* ═══ 元素選取模式 ═══ */
.mdltx-picker-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483645;pointer-events:none}
.mdltx-picker-highlight{position:fixed;pointer-events:none;border:2px solid var(--mdltx-primary);background:rgba(37,99,235,0.1);border-radius:4px;transition:all 0.1s ease;z-index:2147483644}
.mdltx-picker-label{position:fixed;z-index:2147483646;background:var(--mdltx-primary);color:#fff;font-size:11px;font-weight:500;padding:4px 10px;border-radius:6px;pointer-events:none;white-space:pre-line;box-shadow:0 2px 12px rgba(0,0,0,0.25);max-width:400px;line-height:1.4;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.mdltx-picker-toolbar{position:fixed;z-index:2147483647;bottom:20px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:12px;padding:12px 20px;background:var(--mdltx-bg);border:1px solid var(--mdltx-border);border-radius:12px;box-shadow:0 8px 32px var(--mdltx-shadow-lg)}
.mdltx-picker-toolbar-text{font-size:14px;color:var(--mdltx-text)}
.mdltx-picker-toolbar-hint{font-size:12px;color:var(--mdltx-text-secondary)}
.mdltx-picker-toolbar kbd{display:inline-flex;align-items:center;justify-content:center;min-width:24px;height:22px;padding:0 6px;background:var(--mdltx-bg-secondary);border:1px solid var(--mdltx-border);border-radius:4px;font-size:11px;font-weight:500;color:var(--mdltx-text)}
/* ═══ 預覽編輯視窗 ═══ */
.mdltx-preview-modal{width:100%;max-width:900px;max-height:calc(100vh - 40px);background:var(--mdltx-bg);border-radius:16px;box-shadow:0 24px 48px var(--mdltx-shadow-lg);display:flex;flex-direction:column;transform:scale(0.95);transition:transform 0.2s ease}
.mdltx-modal-overlay.open .mdltx-preview-modal{transform:scale(1)}
.mdltx-preview-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--mdltx-border);flex-shrink:0}
.mdltx-preview-title{font-size:16px;font-weight:600;color:var(--mdltx-text);display:flex;align-items:center;gap:8px}
.mdltx-preview-actions{display:flex;align-items:center;gap:8px}
.mdltx-preview-tabs{display:flex;background:var(--mdltx-bg-secondary);border-radius:8px;padding:3px}
.mdltx-preview-tab{padding:6px 14px;border:none;background:none;color:var(--mdltx-text-secondary);font-size:13px;font-weight:500;cursor:pointer;border-radius:6px;transition:all 0.15s ease;font-family:inherit}
.mdltx-preview-tab:hover{color:var(--mdltx-text)}
.mdltx-preview-tab.active{background:var(--mdltx-bg);color:var(--mdltx-text);box-shadow:0 1px 3px var(--mdltx-shadow)}
.mdltx-preview-body{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}
.mdltx-preview-content{flex:1;overflow:auto;padding:0}
.mdltx-preview-editor{width:100%;height:100%;border:none;resize:none;padding:16px 20px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:14px;line-height:1.6;color:var(--mdltx-text);background:var(--mdltx-bg);outline:none;transition:box-shadow 0.15s ease}
.mdltx-preview-editor:focus{box-shadow:inset 0 0 0 2px rgba(37,99,235,0.15)}
.mdltx-preview-rendered{padding:16px 20px;font-size:14px;line-height:1.7;color:var(--mdltx-text)}
.mdltx-preview-rendered pre{background:var(--mdltx-bg-secondary);border:1px solid var(--mdltx-border);border-radius:8px;padding:12px 16px;overflow-x:auto;margin:12px 0}
.mdltx-preview-rendered code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px}
.mdltx-preview-rendered p{margin:8px 0}
.mdltx-preview-rendered h1,.mdltx-preview-rendered h2,.mdltx-preview-rendered h3{margin:16px 0 8px;font-weight:600}
.mdltx-preview-rendered ul,.mdltx-preview-rendered ol{margin:8px 0;padding-left:24px}
.mdltx-preview-rendered blockquote{border-left:3px solid var(--mdltx-border);padding-left:16px;margin:12px 0;color:var(--mdltx-text-secondary)}
.mdltx-preview-rendered table{border-collapse:collapse;margin:12px 0;width:100%}
.mdltx-preview-rendered th,.mdltx-preview-rendered td{border:1px solid var(--mdltx-border);padding:8px 12px;text-align:left}
.mdltx-preview-rendered th{background:var(--mdltx-bg-secondary);font-weight:600}
.mdltx-preview-rendered hr{border:none;border-top:1px solid var(--mdltx-border);margin:16px 0}
.mdltx-preview-rendered table tr:nth-child(even) td{background:rgba(0,0,0,0.02)}
.mdltx-root[data-theme="dark"] .mdltx-preview-rendered table tr:nth-child(even) td{background:rgba(255,255,255,0.03)}
.mdltx-preview-rendered li.task{list-style:none;margin-left:-20px}
.mdltx-preview-rendered li.task input[type="checkbox"]{margin-right:6px;accent-color:var(--mdltx-primary);width:15px;height:15px;vertical-align:middle}
.mdltx-preview-rendered li.task.done{color:var(--mdltx-text-secondary);text-decoration:line-through;text-decoration-color:var(--mdltx-border)}
.mdltx-preview-rendered img{border-radius:8px;border:1px solid var(--mdltx-border);max-width:100%}
.mdltx-preview-rendered del{color:var(--mdltx-text-secondary);text-decoration-color:var(--mdltx-error)}
.mdltx-preview-rendered mark{background:rgba(250,204,21,0.3);padding:1px 4px;border-radius:2px}
.mdltx-preview-rendered sup,.mdltx-preview-rendered sub{font-size:0.8em;line-height:0}
.mdltx-preview-rendered h1{font-size:1.6em;border-bottom:1px solid var(--mdltx-border);padding-bottom:8px}
.mdltx-preview-rendered h2{font-size:1.35em;border-bottom:1px solid var(--mdltx-border);padding-bottom:6px}
.mdltx-preview-rendered h3{font-size:1.15em}
.math-block{position:relative}
.math-block-copy{position:absolute;top:6px;right:8px;padding:2px 8px;border:1px solid var(--mdltx-border);border-radius:4px;background:var(--mdltx-bg);color:var(--mdltx-text-secondary);font-size:10px;cursor:pointer;opacity:0;transition:opacity 0.15s ease;font-family:system-ui,sans-serif}
.math-block:hover .math-block-copy{opacity:1}
.math-block-copy:hover{background:var(--mdltx-bg-secondary);color:var(--mdltx-text)}
.mdltx-preview-rendered a{color:var(--mdltx-primary);text-decoration:none}
.mdltx-preview-rendered a:hover{text-decoration:underline}
.mdltx-preview-footer{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid var(--mdltx-border);flex-shrink:0}
.mdltx-preview-stats{display:flex;gap:16px;font-size:12px;color:var(--mdltx-text-secondary)}
.mdltx-preview-stat{display:flex;align-items:center;gap:4px}
.mdltx-preview-buttons{display:flex;gap:8px}
/* ═══ 增強預覽視窗 ═══ */
.mdltx-preview-modal.fullscreen{max-width:100%;max-height:100%;width:100%;height:100%;border-radius:0}
.mdltx-preview-modal.split-view .mdltx-preview-body{flex-direction:row}
.mdltx-preview-modal.split-view .mdltx-preview-content{flex:1;display:flex;flex-direction:row;gap:0}
.mdltx-preview-modal.split-view .mdltx-preview-pane{flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--mdltx-border)}
.mdltx-preview-modal.split-view .mdltx-preview-pane:last-child{border-right:none}
.mdltx-preview-modal.split-view .mdltx-preview-pane-header{padding:8px 12px;background:var(--mdltx-bg-secondary);font-size:12px;font-weight:600;color:var(--mdltx-text-secondary);border-bottom:1px solid var(--mdltx-border)}
.mdltx-preview-modal.split-view .mdltx-preview-editor{border:none;flex:1;resize:none}
.mdltx-preview-modal.split-view .mdltx-preview-rendered{flex:1;overflow:auto}
.mdltx-preview-toolbar{display:flex;align-items:center;gap:4px;padding:8px 12px;border-bottom:1px solid var(--mdltx-border);background:var(--mdltx-bg-secondary);flex-wrap:wrap}
.mdltx-preview-toolbar-group{display:flex;align-items:center;gap:2px;padding-right:8px;border-right:1px solid var(--mdltx-border);margin-right:8px}
.mdltx-preview-toolbar-group:last-child{border-right:none;margin-right:0;padding-right:0}
.mdltx-toolbar-btn{width:28px;height:28px;padding:4px;border:none;background:none;color:var(--mdltx-text-secondary);cursor:pointer;border-radius:4px;display:flex;align-items:center;justify-content:center;transition:all 0.15s ease}
.mdltx-toolbar-btn:hover{background:var(--mdltx-bg-tertiary);color:var(--mdltx-text)}
.mdltx-toolbar-btn:active{transform:scale(0.95)}
.mdltx-toolbar-btn.active{background:var(--mdltx-primary);color:#fff}
.mdltx-toolbar-btn svg{width:16px;height:16px}
.mdltx-preview-view-toggle{display:flex;background:var(--mdltx-bg-secondary);border-radius:6px;padding:2px;margin-left:auto}
.mdltx-preview-view-btn{padding:4px 10px;border:none;background:none;color:var(--mdltx-text-secondary);font-size:12px;cursor:pointer;border-radius:4px;transition:all 0.15s ease;display:flex;align-items:center;gap:4px}
.mdltx-preview-view-btn:hover{color:var(--mdltx-text)}
.mdltx-preview-view-btn.active{background:var(--mdltx-bg);color:var(--mdltx-text);box-shadow:0 1px 2px var(--mdltx-shadow)}
.mdltx-preview-view-btn svg{width:14px;height:14px}
/* ═══ 元素選取工具欄增強 ═══ */
.mdltx-picker-toolbar{gap:16px}
.mdltx-picker-exit-btn{padding:8px 16px;border:1px solid var(--mdltx-error);border-radius:8px;background:transparent;color:var(--mdltx-error);font-size:13px;font-weight:500;cursor:pointer;transition:all 0.15s ease;display:flex;align-items:center;gap:6px}
.mdltx-picker-exit-btn:hover{background:var(--mdltx-error);color:#fff}
.mdltx-picker-exit-btn svg{width:16px;height:16px}
/* ═══ 匯出匯入對話框 ═══ */
.mdltx-import-dialog{position:absolute;top:0;left:0;right:0;bottom:0;background:var(--mdltx-overlay);display:flex;align-items:center;justify-content:center;padding:20px;z-index:10;border-radius:16px}
.mdltx-import-dialog-inner{background:var(--mdltx-bg);border:1px solid var(--mdltx-border);border-radius:12px;padding:20px;width:100%;max-width:480px;box-shadow:0 8px 32px var(--mdltx-shadow-lg)}
.mdltx-import-dialog-title{font-size:15px;font-weight:600;margin-bottom:12px;color:var(--mdltx-text)}
.mdltx-import-dialog-textarea{width:100%;min-height:160px;padding:12px;border:1px solid var(--mdltx-border);border-radius:8px;background:var(--mdltx-bg);color:var(--mdltx-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;resize:vertical;outline:none;transition:border-color 0.15s ease}
.mdltx-import-dialog-textarea:focus{border-color:var(--mdltx-primary);box-shadow:var(--mdltx-focus-ring)}
.mdltx-import-dialog-hint{font-size:12px;color:var(--mdltx-text-secondary);margin-top:8px;margin-bottom:16px}
.mdltx-import-dialog-buttons{display:flex;justify-content:flex-end;gap:8px}
.mdltx-btn-icon-sm{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px solid var(--mdltx-border);border-radius:8px;background:var(--mdltx-bg);color:var(--mdltx-text);font-family:inherit;font-size:13px;cursor:pointer;transition:all 0.15s ease}
.mdltx-btn-icon-sm:hover{background:var(--mdltx-bg-secondary)}
.mdltx-btn-icon-sm svg{width:14px;height:14px}
/* ═══ 工具列按鈕 hover tooltip ═══ */
.mdltx-toolbar-btn{position:relative}
.mdltx-toolbar-btn[title]::after{content:attr(title);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:3px 8px;border-radius:4px;background:var(--mdltx-bg-secondary);border:1px solid var(--mdltx-border);color:var(--mdltx-text);font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity 0.15s ease 0.3s;z-index:1}
.mdltx-toolbar-btn[title]:hover::after{opacity:1}
`;
// ─────────────────────────────────────────────────────────────
// § DOM 工具
// ─────────────────────────────────────────────────────────────
function createElement(tag, attrs = {}, children = []) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'className') el.className = value;
else if (key === 'textContent') el.textContent = value;
else if (key === 'value') el.value = value;
else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2).toLowerCase(), value);
else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value);
else if (key === 'dataset' && typeof value === 'object') Object.assign(el.dataset, value);
else el.setAttribute(key, value);
}
for (const child of children) {
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
else if (child instanceof Node) el.appendChild(child);
}
return el;
}
function sanitizeFilename(name) {
const fallback = t('defaultDocumentName') || 'document';
return String(name || fallback).replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 100) || fallback;
}
function getFilenameTokens() {
const title = sanitizeFilename(document.title || 'untitled');
const now = new Date();
const date = now.toISOString().slice(0, 10);
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
const timestamp = Date.now().toString();
const host = sanitizeFilename(location.hostname || 'site');
const rawPath = (location.pathname || '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
const path = sanitizeFilename(rawPath || 'page');
const slug = sanitizeFilename(rawPath.split('/').filter(Boolean).pop() || title);
return { title, date, time, timestamp, host, path, slug };
}
function generateFilename() {
const tokens = getFilenameTokens();
const template = S.get('downloadFilenameTemplate') || '{title}_{date}';
let filename = template;
for (const [key, value] of Object.entries(tokens)) {
filename = filename.split(`{${key}}`).join(value);
}
filename = sanitizeFilename(filename);
return (filename || tokens.title || 'document') + '.md';
}
function downloadAsFile(content, filename) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }), url = URL.createObjectURL(blob);
const a = createElement('a', { href: url, download: filename, style: { display: 'none' } });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return filename;
}
/**
* 安全地設定元素的 HTML 內容(Trusted Types 相容)
* 第二階段將擴展此函數以支援 Trusted Type policy
*/
let trustedHtmlPolicy = null;
function getTrustedHtmlPolicy() {
if (trustedHtmlPolicy) return trustedHtmlPolicy;
if (!supportsTrustedTypes()) return null;
try {
trustedHtmlPolicy = window.trustedTypes.createPolicy('mdltx#safe', { createHTML: input => input });
return trustedHtmlPolicy;
} catch (e) {
console.warn('[mdltx] Failed to create Trusted Types policy:', e);
return null;
}
}
function safeSetInnerHTML(el, html) {
while (el.firstChild) el.removeChild(el.firstChild);
const policy = getTrustedHtmlPolicy();
if (policy) {
const template = document.createElement('template');
template.innerHTML = policy.createHTML(html);
el.appendChild(template.content.cloneNode(true));
return;
}
if (supportsTrustedTypes()) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const fragment = document.createDocumentFragment();
Array.from(doc.body.childNodes).forEach(node => fragment.appendChild(node));
el.appendChild(fragment);
return;
} catch (e) {
console.warn('[mdltx] DOMParser fallback failed:', e);
}
}
const template = document.createElement('template');
template.innerHTML = html;
el.appendChild(template.content.cloneNode(true));
}
/**
* 將設定匯出為 JSON 字串
*/
function exportSettings() {
const settings = S.getAll();
return JSON.stringify(settings, null, 2);
}
/**
* 從 JSON 字串匯入設定
* @returns {{ success: boolean, importedCount: number, ignoredCount: number }} 匯入結果
*/
function importSettings(jsonString) {
try {
const parsed = JSON.parse(jsonString);
if (typeof parsed !== 'object' || parsed === null) return { success: false, importedCount: 0, ignoredCount: 0 };
let importedCount = 0, ignoredCount = 0;
for (const [k, v] of Object.entries(parsed)) {
if (k in DEFAULTS && k in SETTING_TYPES) {
const type = SETTING_TYPES[k];
if (type === 'boolean' && typeof v === 'boolean') { S.set(k, v); importedCount++; }
else if (type === 'number' && typeof v === 'number' && !isNaN(v)) { S.set(k, v); importedCount++; }
else if (type === 'string' && typeof v === 'string') { S.set(k, v); importedCount++; }
else ignoredCount++;
} else {
ignoredCount++;
}
}
if (importedCount > 0) migrateSettings();
const result = { success: importedCount > 0, importedCount, ignoredCount };
if (result.success) diagLog('Settings imported', result);
return result;
} catch (e) {
console.warn('[mdltx] importSettings error:', e);
return { success: false, importedCount: 0, ignoredCount: 0 };
}
}
/**
* 偵測是否支援 Trusted Types(為第二階段 safeSetInnerHTML 擴展準備)
*/
function supportsTrustedTypes() {
try {
return typeof window.trustedTypes !== 'undefined' && typeof window.trustedTypes.createPolicy === 'function';
} catch { return false; }
}
/**
* 生成 YAML Frontmatter
*/
function generateFrontmatter() {
if (!S.get('downloadFrontmatter')) return '';
const lines = ['---'];
if (S.get('frontmatterTitle')) {
const title = (document.title || 'Untitled').replace(/"/g, '\\"').replace(/\n/g, ' ');
lines.push(`title: "${title}"`);
}
if (S.get('frontmatterDate')) {
lines.push(`date: ${new Date().toISOString().split('T')[0]}`);
}
if (S.get('frontmatterUrl')) {
lines.push(`url: "${location.href}"`);
}
if (S.get('frontmatterDescription')) {
const desc = document.querySelector('meta[name="description"]')?.getAttribute('content') ||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') || '';
if (desc) lines.push(`description: "${desc.replace(/"/g, '\\"').replace(/\n/g, ' ').slice(0, 300)}"`);
}
if (S.get('frontmatterAuthor')) {
const author = document.querySelector('meta[name="author"]')?.getAttribute('content') ||
document.querySelector('meta[property="article:author"]')?.getAttribute('content') || '';
if (author) lines.push(`author: "${author.replace(/"/g, '\\"')}"`);
}
if (S.get('frontmatterTags')) {
const keywords = document.querySelector('meta[name="keywords"]')?.getAttribute('content') || '';
if (keywords) {
const tags = keywords.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10);
if (tags.length) lines.push(`tags: [${tags.map(t => `"${t.replace(/"/g, '\\"')}"`).join(', ')}]`);
}
}
lines.push(`source: "${location.hostname}"`);
lines.push(`captured: ${new Date().toISOString()}`);
const custom = S.get('frontmatterCustom');
if (custom) {
const customLines = custom.split('\n').map(l => l.trim()).filter(l => l && l.includes(':'));
for (const line of customLines) lines.push(line);
}
lines.push('---', '');
return lines.join('\n');
}
class FocusTrap {
constructor(container) { this.container = container; this.prev = null; this._onKey = this._onKey.bind(this); }
activate() {
this.prev = document.activeElement;
this.container.addEventListener('keydown', this._onKey);
requestAnimationFrame(() => { const f = this._focusable()[0]; if (f) f.focus(); });
}
deactivate() {
this.container.removeEventListener('keydown', this._onKey);
if (this.prev?.focus) try { this.prev.focus(); } catch {}
this.prev = null;
}
_focusable() {
return Array.from(this.container.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),a[href]')).filter(el => {
if (el.getAttribute('tabindex') === '0') return true;
try { const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden'; } catch { return el.offsetParent !== null; }
});
}
_onKey(e) {
if (e.key !== 'Tab') return;
const f = this._focusable(); if (!f.length) return;
const first = f[0], last = f[f.length - 1], act = (this.container.getRootNode?.() || document).activeElement;
if (e.shiftKey) { if (act === first || !f.includes(act)) { e.preventDefault(); last.focus(); } }
else { if (act === last || !f.includes(act)) { e.preventDefault(); first.focus(); } }
}
}
/**
* 元素選取器 - 讓使用者用滑鼠選取頁面元素
*/
class ElementPicker {
constructor(ui) {
this.ui = ui;
this.active = false;
this.overlay = null;
this.highlight = null;
this.label = null;
this.toolbar = null;
this.currentElement = null;
this.onComplete = null;
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onClick = this._onClick.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onScroll = this._onScroll.bind(this);
}
start(onComplete) {
if (this.active) return;
this.active = true;
this.onComplete = onComplete;
this._createOverlay();
this._bindEvents();
if (this.ui.menuOpen) this.ui.hideMenu(false);
document.body.style.cursor = 'crosshair';
}
stop() {
if (!this.active) return;
this.active = false;
this._unbindEvents();
this._removeOverlay();
document.body.style.cursor = '';
this.currentElement = null;
}
_createOverlay() {
const root = this.ui.root;
if (!root) return;
this.overlay = createElement('div', { className: 'mdltx-picker-overlay' });
this.highlight = createElement('div', { className: 'mdltx-picker-highlight' });
this.highlight.style.display = 'none';
this.label = createElement('div', { className: 'mdltx-picker-label' });
this.label.style.display = 'none';
// 工具欄增加退出按鈕
const exitBtn = createElement('button', { className: 'mdltx-picker-exit-btn', type: 'button' }, [
createIcon('xCircle', 16),
document.createTextNode(' ' + t('pickerExit'))
]);
exitBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.stop();
});
this.toolbar = createElement('div', { className: 'mdltx-picker-toolbar' }, [
createElement('span', { className: 'mdltx-picker-toolbar-text', textContent: t('pickerModeActive') }),
createElement('span', { className: 'mdltx-picker-toolbar-hint' }, [
createElement('kbd', { textContent: '↑↓←→' }),
document.createTextNode(' '),
createElement('kbd', { textContent: 'Enter' }),
document.createTextNode(' '),
createElement('kbd', { textContent: 'ESC' }),
]),
exitBtn,
]);
root.append(this.overlay, this.highlight, this.label, this.toolbar);
}
_removeOverlay() {
this.overlay?.remove();
this.highlight?.remove();
this.label?.remove();
this.toolbar?.remove();
this.overlay = this.highlight = this.label = this.toolbar = null;
}
_bindEvents() {
document.addEventListener('mousemove', this._onMouseMove, true);
document.addEventListener('mousedown', this._onMouseDown, true);
document.addEventListener('click', this._onClick, true);
document.addEventListener('keydown', this._onKeyDown, true);
window.addEventListener('scroll', this._onScroll, true);
}
_unbindEvents() {
document.removeEventListener('mousemove', this._onMouseMove, true);
document.removeEventListener('mousedown', this._onMouseDown, true);
document.removeEventListener('click', this._onClick, true);
document.removeEventListener('keydown', this._onKeyDown, true);
window.removeEventListener('scroll', this._onScroll, true);
}
_onMouseMove(e) {
if (!this.active) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === this.currentElement || isOurUI(el)) return;
if (el.tagName === 'HTML' || el.tagName === 'BODY') return;
this.currentElement = el;
this._updateHighlight(el);
}
_updateHighlight(el) {
if (!el || !this.highlight || !this.label) return;
const rect = el.getBoundingClientRect();
const h = this.highlight.style;
const l = this.label.style;
h.display = 'block';
h.top = `${rect.top}px`;
h.left = `${rect.left}px`;
h.width = `${rect.width}px`;
h.height = `${rect.height}px`;
let labelText = el.tagName.toLowerCase();
if (el.id) labelText += `#${el.id}`;
else if (el.className && typeof el.className === 'string') {
const classes = el.className.trim().split(/\s+/).slice(0, 2);
if (classes.length) labelText += '.' + classes.join('.');
}
const textLen = (el.textContent || '').trim().length;
const childCount = el.children?.length || 0;
labelText += ` (${Math.round(rect.width)}×${Math.round(rect.height)}`;
if (childCount > 0) labelText += `, ${childCount} children`;
labelText += `, ${textLen} chars)`;
// 文字預覽(截斷)
const preview = (el.textContent || '').trim().slice(0, 60);
if (preview && preview.length > 0) {
const truncated = preview.length >= 60 ? preview + '…' : preview;
labelText += `\n"${truncated}"`;
}
this.label.textContent = labelText;
l.display = 'block';
const labelRect = this.label.getBoundingClientRect();
let labelTop = rect.top - labelRect.height - 4;
let labelLeft = rect.left;
if (labelTop < 0) labelTop = rect.bottom + 4;
if (labelLeft + labelRect.width > window.innerWidth) labelLeft = window.innerWidth - labelRect.width - 4;
l.top = `${Math.max(0, labelTop)}px`;
l.left = `${Math.max(0, labelLeft)}px`;
}
_onMouseDown(e) {
if (!this.active || !this.currentElement) return;
if (isOurUI(e.target)) return;
e.preventDefault();
e.stopPropagation();
const el = this.currentElement;
this.stop();
if (this.onComplete) this.onComplete(el);
}
_onClick(e) {
if (!this.active) return;
if (isOurUI(e.target)) return;
e.preventDefault();
e.stopPropagation();
}
_onKeyDown(e) {
if (!this.active) return;
if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
this.stop();
return;
}
// ═══ 方向鍵導覽 ═══
if (!this.currentElement) return;
let target = null;
switch (e.key) {
case 'ArrowUp':
e.preventDefault(); e.stopPropagation();
target = this._getNavigableParent(this.currentElement);
break;
case 'ArrowDown':
e.preventDefault(); e.stopPropagation();
target = this._getFirstNavigableChild(this.currentElement);
break;
case 'ArrowLeft':
e.preventDefault(); e.stopPropagation();
target = this._getPrevNavigableSibling(this.currentElement);
break;
case 'ArrowRight':
e.preventDefault(); e.stopPropagation();
target = this._getNextNavigableSibling(this.currentElement);
break;
case 'Enter':
case ' ':
// Enter / Space = 確認選取(同滑鼠點擊)
e.preventDefault(); e.stopPropagation();
const el = this.currentElement;
this.stop();
if (this.onComplete) this.onComplete(el);
return;
}
if (target) this._navigateTo(target);
}
_onScroll() { if (this.currentElement) this._updateHighlight(this.currentElement); }
/** 判斷元素是否適合做為導覽目標 */
_isNavigable(el) {
if (!el || el.nodeType !== 1) return false;
if (/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|MJX-ASSISTIVE-MML|BR|HR)$/i.test(el.tagName)) return false;
if (isOurUI(el)) return false;
try {
const rect = el.getBoundingClientRect();
// 跳過完全沒有尺寸的元素(但保留 0 寬或 0 高的行內元素)
if (rect.width === 0 && rect.height === 0) return false;
} catch { return false; }
return true;
}
/** 取得父元素(可導覽的) */
_getNavigableParent(el) {
if (!el) return null;
let parent = el.parentElement;
while (parent) {
if (parent.tagName === 'HTML' || parent.tagName === 'BODY') return parent;
if (this._isNavigable(parent)) return parent;
parent = parent.parentElement;
}
return null;
}
/** 取得第一個可導覽的子元素 */
_getFirstNavigableChild(el) {
if (!el) return null;
for (const child of el.children) {
if (this._isNavigable(child)) return child;
}
return null;
}
/** 取得前一個可導覽的兄弟元素 */
_getPrevNavigableSibling(el) {
if (!el) return null;
let sibling = el.previousElementSibling;
while (sibling) {
if (this._isNavigable(sibling)) return sibling;
sibling = sibling.previousElementSibling;
}
return null;
}
/** 取得後一個可導覽的兄弟元素 */
_getNextNavigableSibling(el) {
if (!el) return null;
let sibling = el.nextElementSibling;
while (sibling) {
if (this._isNavigable(sibling)) return sibling;
sibling = sibling.nextElementSibling;
}
return null;
}
/** 導覽到指定元素 */
_navigateTo(el) {
if (!el || !this.active) return;
this.currentElement = el;
this._updateHighlight(el);
// 確保元素可見
try {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
} catch {
try { el.scrollIntoView(false); } catch {}
}
}
}
/**
* 預覽編輯視窗 - 增強版
*/
class PreviewModal {
constructor(ui) {
this.ui = ui;
this.modal = null;
this.content = '';
this.mode = 'preview'; // 'preview' | 'edit' | 'split'
this.isFullscreen = false;
this._focusTrap = null;
this._editorRef = null;
}
async show(markdown, options = {}) {
this._originalContent = markdown;
this.content = markdown;
this.options = options;
this.mode = S.get('previewSplitView') ? 'split' : (S.get('previewDefaultMode') || 'preview');
this.isFullscreen = false;
this._createModal();
// 更新標題顯示
const titleEl = this.modal?.querySelector('#mdltx-preview-title');
if (titleEl) {
// 如果包含 Frontmatter,顯示標籤
if (options?.includedFrontmatter) {
const badge = createElement('span', {
style: {
marginLeft: '8px',
fontSize: '11px',
padding: '2px 6px',
background: 'var(--mdltx-primary)',
color: '#fff',
borderRadius: '4px',
fontWeight: 'normal'
},
textContent: 'Frontmatter'
});
titleEl.appendChild(badge);
}
// 如果是元素選取模式,顯示元素資訊
if (options?.elementInfo) {
const elementBadge = createElement('span', {
style: {
marginLeft: '8px',
fontSize: '11px',
padding: '2px 6px',
background: 'var(--mdltx-success)',
color: '#fff',
borderRadius: '4px',
fontWeight: 'normal',
fontFamily: 'monospace'
},
textContent: options.elementInfo
});
titleEl.appendChild(elementBadge);
}
// 顯示模式標籤
if (options?.mode && options.mode !== 'element') {
const modeLabels = {
selection: t('modeSelection'),
article: t('modeArticleLabel'),
page: t('modePageLabel')
};
const modeLabel = modeLabels[options.mode];
if (modeLabel) {
const modeBadge = createElement('span', {
style: {
marginLeft: '8px',
fontSize: '11px',
padding: '2px 6px',
background: 'var(--mdltx-bg-tertiary)',
color: 'var(--mdltx-text-secondary)',
borderRadius: '4px',
fontWeight: 'normal'
},
textContent: modeLabel
});
titleEl.appendChild(modeBadge);
}
}
}
this._bindEvents();
this._updateView();
this._updateStats();
await new Promise(r => requestAnimationFrame(r));
this.modal.classList.add('open');
this._focusTrap = new FocusTrap(this.modal.querySelector('.mdltx-preview-modal'));
this._focusTrap.activate();
}
close(force = false) {
if (!this.modal) return;
// 檢查是否有未儲存的編輯
if (!force && this._originalContent !== undefined && this.content !== this._originalContent) {
if (!confirm(t('unsavedChangesWarning'))) return;
}
if (this._focusTrap) { this._focusTrap.deactivate(); this._focusTrap = null; }
this.modal.classList.remove('open');
this._originalContent = undefined;
setTimeout(() => { this.modal?.remove(); this.modal = null; }, 200);
}
_createModal() {
const root = this.ui.root;
if (!root) return;
root.querySelector('.mdltx-modal-overlay.preview-modal')?.remove();
const overlay = createElement('div', { className: 'mdltx-modal-overlay preview-modal', tabindex: '-1' });
const modal = createElement('div', { className: 'mdltx-preview-modal', role: 'dialog', 'aria-labelledby': 'mdltx-preview-title', 'aria-modal': 'true' });
// 標題列
const header = createElement('div', { className: 'mdltx-preview-header' }, [
createElement('div', { className: 'mdltx-preview-title', id: 'mdltx-preview-title' }, [
createIcon('fileText', 18), document.createTextNode(' ' + t('previewTitle')),
]),
createElement('div', { className: 'mdltx-preview-actions' }, [
// 視圖切換
createElement('div', { className: 'mdltx-preview-view-toggle' }, [
createElement('button', { className: `mdltx-preview-view-btn ${this.mode === 'edit' ? 'active' : ''}`, type: 'button', dataset: { mode: 'edit' }, title: t('previewModeEdit') }, [
createIcon('edit3', 14), document.createTextNode(' ' + t('previewModeEdit'))
]),
createElement('button', { className: `mdltx-preview-view-btn ${this.mode === 'split' ? 'active' : ''}`, type: 'button', dataset: { mode: 'split' }, title: t('previewSplitView') }, [
createIcon('columns', 14), document.createTextNode(' ' + t('previewSplitView'))
]),
createElement('button', { className: `mdltx-preview-view-btn ${this.mode === 'preview' ? 'active' : ''}`, type: 'button', dataset: { mode: 'preview' }, title: t('previewModePreview') }, [
createIcon('eye', 14), document.createTextNode(' ' + t('previewModePreview'))
]),
]),
// 全螢幕按鈕
createElement('button', { className: 'mdltx-toolbar-btn', type: 'button', id: 'preview-fullscreen-btn', title: t('previewFullscreen') }, [createIcon('maximize', 18)]),
// 關閉按鈕
createElement('button', { className: 'mdltx-modal-close', type: 'button', 'aria-label': t('close') }, [createIcon('x', 18)]),
]),
]);
// 編輯工具列
const toolbar = createElement('div', { className: 'mdltx-preview-toolbar', id: 'mdltx-preview-toolbar' }, [
createElement('div', { className: 'mdltx-preview-toolbar-group' }, [
this._createToolBtn('bold', t('toolBold'), () => this._insertFormat('**', '**')),
this._createToolBtn('italic', t('toolItalic'), () => this._insertFormat('*', '*')),
this._createToolBtn('code', t('toolCode'), () => this._insertFormat('`', '`')),
]),
createElement('div', { className: 'mdltx-preview-toolbar-group' }, [
this._createToolBtn('heading', t('toolHeading'), () => this._insertPrefix('## ')),
this._createToolBtn('list', t('toolList'), () => this._insertPrefix('- ')),
this._createToolBtn('quote', t('toolQuote'), () => this._insertPrefix('> ')),
]),
createElement('div', { className: 'mdltx-preview-toolbar-group' }, [
this._createToolBtn('link', t('toolLink'), () => this._insertFormat('[', '](url)')),
this._createToolBtn('minus', t('toolHr'), () => this._insertBlock('\n\n---\n\n')),
]),
]);
// 內容區
const body = createElement('div', { className: 'mdltx-preview-body' }, [
createElement('div', { className: 'mdltx-preview-content', id: 'mdltx-preview-content' }),
]);
// 底部
const footer = createElement('div', { className: 'mdltx-preview-footer' }, [
createElement('div', { className: 'mdltx-preview-stats', id: 'mdltx-preview-stats' }),
createElement('div', { className: 'mdltx-preview-buttons' }, [
createElement('button', { className: 'mdltx-btn-secondary', type: 'button', id: 'preview-copy-btn' }, [
createIcon('copy', 16), document.createTextNode(' ' + t('previewCopyBtn')),
]),
createElement('button', { className: 'mdltx-btn-primary', type: 'button', id: 'preview-download-btn' }, [
createIcon('download', 16), document.createTextNode(' ' + t('previewDownloadBtn')),
]),
]),
]);
modal.append(header, toolbar, body, footer);
overlay.appendChild(modal);
root.appendChild(overlay);
this.modal = overlay;
}
_createToolBtn(icon, title, onClick) {
const btn = createElement('button', { className: 'mdltx-toolbar-btn', type: 'button', title }, [createIcon(icon, 16)]);
btn.addEventListener('click', onClick);
return btn;
}
_bindEvents() {
if (!this.modal) return;
this.modal.querySelector('.mdltx-modal-close')?.addEventListener('click', () => this.close());
this.modal.addEventListener('click', e => { if (e.target === this.modal) this.close(); });
this.modal.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); this.close(); } });
// 編輯器快捷鍵(Ctrl/Cmd + B/I/K/`)
this.modal.addEventListener('keydown', e => this._handleEditorHotkeys(e), true);
// 視圖切換
this.modal.querySelectorAll('.mdltx-preview-view-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.mode = btn.dataset.mode;
this._updateViewState();
this._updateView();
});
});
// 全螢幕
this.modal.querySelector('#preview-fullscreen-btn')?.addEventListener('click', () => this._toggleFullscreen());
// 複製/下載
this.modal.querySelector('#preview-copy-btn')?.addEventListener('click', () => this._handleCopy());
this.modal.querySelector('#preview-download-btn')?.addEventListener('click', () => this._handleDownload());
}
_updateViewState() {
this.modal?.querySelectorAll('.mdltx-preview-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === this.mode);
});
const modalEl = this.modal?.querySelector('.mdltx-preview-modal');
if (modalEl) {
modalEl.classList.toggle('split-view', this.mode === 'split');
}
// 工具列顯示/隱藏
const toolbar = this.modal?.querySelector('#mdltx-preview-toolbar');
if (toolbar) {
toolbar.style.display = (this.mode === 'edit' || this.mode === 'split') ? 'flex' : 'none';
}
}
_toggleFullscreen() {
this.isFullscreen = !this.isFullscreen;
const modalEl = this.modal?.querySelector('.mdltx-preview-modal');
const btn = this.modal?.querySelector('#preview-fullscreen-btn');
if (modalEl) {
modalEl.classList.toggle('fullscreen', this.isFullscreen);
}
if (btn) {
while (btn.firstChild) btn.removeChild(btn.firstChild);
btn.appendChild(createIcon(this.isFullscreen ? 'minimize' : 'maximize', 18));
btn.title = this.isFullscreen ? t('previewExitFullscreen') : t('previewFullscreen');
}
}
_updateView() {
const container = this.modal?.querySelector('#mdltx-preview-content');
if (!container) return;
while (container.firstChild) container.removeChild(container.firstChild);
container.style.maxHeight = '';
container.style.overflow = '';
this._updateViewState();
const maxH = `${S.get('previewMaxHeight')}vh`;
const fontSize = `${S.get('previewFontSize')}px`;
if (this.mode === 'edit') {
const textarea = createElement('textarea', { className: 'mdltx-preview-editor', id: 'mdltx-preview-editor', spellcheck: 'false' });
textarea.value = this.content;
textarea.style.fontSize = fontSize;
textarea.style.minHeight = maxH;
textarea.addEventListener('input', () => { this.content = textarea.value; this._updateStats(); });
container.appendChild(textarea);
this._editorRef = textarea;
const updateCursor = () => this._updateCursorPosition();
textarea.addEventListener('keyup', updateCursor);
textarea.addEventListener('click', updateCursor);
textarea.addEventListener('input', updateCursor);
// 初始更新
requestAnimationFrame(updateCursor);
textarea.focus();
} else if (this.mode === 'split') {
// 並列模式
const editPane = createElement('div', { className: 'mdltx-preview-pane' }, [
createElement('div', { className: 'mdltx-preview-pane-header', textContent: t('previewModeEdit') }),
]);
const textarea = createElement('textarea', { className: 'mdltx-preview-editor', id: 'mdltx-preview-editor', spellcheck: 'false' });
textarea.value = this.content;
textarea.style.fontSize = fontSize;
textarea.addEventListener('input', () => {
this.content = textarea.value;
this._updateStats();
this._updatePreviewPane();
});
editPane.appendChild(textarea);
this._editorRef = textarea;
const updateCursor2 = () => this._updateCursorPosition();
textarea.addEventListener('keyup', updateCursor2);
textarea.addEventListener('click', updateCursor2);
textarea.addEventListener('input', updateCursor2);
requestAnimationFrame(updateCursor2);
const previewPane = createElement('div', { className: 'mdltx-preview-pane' }, [
createElement('div', { className: 'mdltx-preview-pane-header', textContent: t('previewModePreview') }),
createElement('div', { className: 'mdltx-preview-rendered', id: 'mdltx-preview-rendered' }),
]);
safeSetInnerHTML(previewPane.querySelector('.mdltx-preview-rendered'), this._renderMarkdown(this.content));
container.style.maxHeight = maxH;
container.append(editPane, previewPane);
// ═══ 滾動同步 ═══
this._isSyncing = false;
const renderedEl = previewPane.querySelector('.mdltx-preview-rendered');
const syncScroll = (source, target) => {
if (this._isSyncing || !source || !target) return;
this._isSyncing = true;
requestAnimationFrame(() => {
const sourceMax = source.scrollHeight - source.clientHeight;
if (sourceMax > 0) {
const ratio = source.scrollTop / sourceMax;
const targetMax = target.scrollHeight - target.clientHeight;
target.scrollTop = ratio * targetMax;
}
// 使用 setTimeout 而非立即解除,避免對方的 scroll 事件回彈
setTimeout(() => { this._isSyncing = false; }, 50);
});
};
textarea.addEventListener('scroll', () => syncScroll(textarea, renderedEl));
if (renderedEl) {
renderedEl.addEventListener('scroll', () => syncScroll(renderedEl, textarea));
}
} else {
// 純預覽
const rendered = createElement('div', { className: 'mdltx-preview-rendered', id: 'mdltx-preview-rendered' });
safeSetInnerHTML(rendered, this._renderMarkdown(this.content));
this._bindMathCopyHandlers(rendered);
rendered.style.maxHeight = maxH;
container.appendChild(rendered);
this._editorRef = null;
}
}
_updatePreviewPane() {
const rendered = this.modal?.querySelector('#mdltx-preview-rendered');
if (rendered) {
safeSetInnerHTML(rendered, this._renderMarkdown(this.content));
this._bindMathCopyHandlers(rendered);
}
}
_handleEditorHotkeys(e) {
const editor = this._editorRef;
if (!editor) return;
const activeEl = (this.modal?.querySelector('.mdltx-preview-modal')?.getRootNode?.()?.activeElement) || document.activeElement;
if (activeEl !== editor) return;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform || '') ||
(navigator.userAgentData?.platform || '').toLowerCase().includes('mac');
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (!modKey || e.altKey) return;
const key = (e.key || '').toLowerCase();
const code = (e.code || '');
if (key === 'b' && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
this._insertFormat('**', '**');
} else if (key === 'i' && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
this._insertFormat('*', '*');
} else if (key === 'k' && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
this._insertFormat('[', '](url)');
} else if ((key === '`' || code === 'Backquote') && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
this._insertFormat('`', '`');
}
}
_bindMathCopyHandlers(container) {
if (!container || container.dataset?.mdltxMathCopyBound === '1') return;
container.dataset.mdltxMathCopyBound = '1';
container.addEventListener('click', async (e) => {
const btn = e.target?.closest?.('.math-block-copy');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const latex = (btn.dataset?.latex || '').trim();
if (!latex) return;
try {
await setClipboardText('$$\n' + latex + '\n$$');
const old = btn.textContent;
btn.textContent = '✓';
setTimeout(() => { btn.textContent = old || 'Copy'; }, 1200);
} catch {}
}, true);
}
// 編輯工具方法
_insertFormat(before, after) {
const editor = this._editorRef;
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selected = this.content.substring(start, end);
const textToWrap = selected || 'text';
const newText = before + textToWrap + after;
// 更新內容
this.content = this.content.substring(0, start) + newText + this.content.substring(end);
editor.value = this.content;
// 計算新的游標位置
let newStart, newEnd;
if (selected) {
// 有選取文字:選取被包裹的文字
newStart = start + before.length;
newEnd = newStart + selected.length;
} else {
// 沒有選取:選取預設的 "text"
newStart = start + before.length;
newEnd = newStart + textToWrap.length;
}
// 使用 requestAnimationFrame 確保 DOM 更新後再設置游標
requestAnimationFrame(() => {
editor.focus();
editor.setSelectionRange(newStart, newEnd);
// 確保游標可見
this._scrollToCursor(editor, newStart);
});
this._updateStats();
if (this.mode === 'split') this._updatePreviewPane();
}
_insertPrefix(prefix) {
const editor = this._editorRef;
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selected = this.content.substring(start, end);
// 檢查是否有選取多行文字
if (selected && selected.includes('\n')) {
// 多行處理:對每一行都加上前綴
const lines = selected.split('\n');
const prefixedLines = lines.map(line => {
// 如果行已經有相同前綴,則跳過
if (line.trimStart().startsWith(prefix.trim())) {
return line;
}
return prefix + line;
});
const newText = prefixedLines.join('\n');
this.content = this.content.substring(0, start) + newText + this.content.substring(end);
editor.value = this.content;
// 保持選取狀態
requestAnimationFrame(() => {
editor.focus();
editor.setSelectionRange(start, start + newText.length);
this._scrollToCursor(editor, start);
});
} else {
// 單行處理:在當前行開頭插入前綴
const lineStart = this.content.lastIndexOf('\n', start - 1) + 1;
const lineEnd = this.content.indexOf('\n', start);
const actualLineEnd = lineEnd === -1 ? this.content.length : lineEnd;
const currentLine = this.content.substring(lineStart, actualLineEnd);
// 檢查是否已有前綴
if (currentLine.trimStart().startsWith(prefix.trim())) {
// 已有前綴,不重複添加
return;
}
this.content = this.content.substring(0, lineStart) + prefix + this.content.substring(lineStart);
editor.value = this.content;
// 游標移到原位置 + 前綴長度
const newPos = start + prefix.length;
requestAnimationFrame(() => {
editor.focus();
editor.setSelectionRange(newPos, newPos);
this._scrollToCursor(editor, newPos);
});
}
this._updateStats();
if (this.mode === 'split') this._updatePreviewPane();
}
_insertBlock(block) {
const editor = this._editorRef;
if (!editor) return;
const pos = editor.selectionStart;
this.content = this.content.substring(0, pos) + block + this.content.substring(pos);
editor.value = this.content;
// 游標移到插入內容之後
const newPos = pos + block.length;
requestAnimationFrame(() => {
editor.focus();
editor.setSelectionRange(newPos, newPos);
this._scrollToCursor(editor, newPos);
});
this._updateStats();
if (this.mode === 'split') this._updatePreviewPane();
}
// 新增:確保游標可見的輔助方法
_scrollToCursor(editor, cursorPos) {
if (!editor) return;
// 計算游標所在行的大致位置
const textBeforeCursor = this.content.substring(0, cursorPos);
const linesBefore = textBeforeCursor.split('\n').length;
const lineHeight = parseInt(window.getComputedStyle(editor).lineHeight) || 20;
const scrollTarget = (linesBefore - 3) * lineHeight; // 留一些上方空間
// 如果游標位置在可視區域外,滾動到游標位置
if (scrollTarget > editor.scrollTop + editor.clientHeight || scrollTarget < editor.scrollTop) {
editor.scrollTop = Math.max(0, scrollTarget);
}
}
_renderMarkdown(md) {
const escapeHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const escapeHtmlAttr = s => String(s)
.replace(/&/g,'&')
.replace(/</g,'<')
.replace(/>/g,'>')
.replace(/"/g,'"')
.replace(/'/g,''');
const sanitizeUrl = (url) => {
const raw = String(url || '').trim();
if (!raw) return '';
if (raw.startsWith('#')) return raw;
if (/^(javascript|data):/i.test(raw)) return '';
try {
const parsed = new URL(raw, location.href);
if (/^(javascript|data):/i.test(parsed.protocol)) return '';
return parsed.href;
} catch {
return raw;
}
};
let html = md;
// ═══ 佔位符策略:保護程式碼區塊和行內程式碼不被後續正則破壞 ═══
const protectedBlocks = [];
const protectBlock = (content) => {
const idx = protectedBlocks.length;
protectedBlocks.push(content);
return `\x00PBLOCK${idx}\x00`;
};
// 程式碼區塊(先處理)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
protectBlock(`<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.trim())}</code></pre>`));
// 行內程式碼(需逸出 HTML 以防止注入)
html = html.replace(/(`+)([^`]+)\1/g, (_, ticks, code) =>
protectBlock(`<code>${escapeHtml(code)}</code>`));
// 數學公式(區塊)——以數學字型顯示原始 LaTeX,附加類型標籤
html = html.replace(/\$\$\n?([\s\S]*?)\n?\$\$/g, (_, content) => {
const escaped = escapeHtml(content.trim());
const rawForCopy = escapeHtmlAttr(content.trim());
return protectBlock('<div class="math-block" style="text-align:center;padding:12px 16px;' +
'background:var(--mdltx-bg-secondary);border:1px solid var(--mdltx-border);' +
'border-radius:8px;margin:12px 0;font-family:\'Cambria Math\',\'Latin Modern Math\',' +
'serif;font-size:15px;line-height:1.8;white-space:pre-wrap;color:var(--mdltx-text);">' +
'<span style="display:block;font-size:10px;opacity:0.4;margin-bottom:4px;' +
'font-family:system-ui,sans-serif;text-transform:uppercase;letter-spacing:0.5px;">LaTeX</span>' +
'<button class="math-block-copy" ' +
'data-latex="' + rawForCopy + '">Copy</button>' +
escaped + '</div>');
});
// 數學公式(行內)——以數學字型顯示,背景色區隔
html = html.replace(/\$([^\$\n]+)\$/g, (_, content) => {
const escaped = escapeHtml(content);
return protectBlock('<span class="math-inline" style="font-family:\'Cambria Math\',\'Latin Modern Math\',' +
'serif;background:var(--mdltx-bg-secondary);padding:1px 5px;border-radius:3px;' +
'font-size:0.95em;">' + escaped + '</span>');
});
// 逸出未受保護的 HTML,避免在預覽中注入
html = html
.split(/(\x00PBLOCK\d+\x00)/)
.map(part => (part.startsWith('\x00PBLOCK') ? part : escapeHtml(part)))
.join('');
// 標題
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// 分隔線
html = html.replace(/^[-*_]{3,}$/gm, '<hr />');
// 任務列表
html = html.replace(/^[-*+]\s+\[x\]\s+(.+)$/gim, '<li class="task done"><input type="checkbox" checked disabled> $1</li>');
html = html.replace(/^[-*+]\s+\[\s?\]\s+(.+)$/gim, '<li class="task"><input type="checkbox" disabled> $1</li>');
// 粗斜體
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
// 粗體
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜體
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>');
// 刪除線
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 圖片
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
const safeUrl = sanitizeUrl(url);
const safeAlt = escapeHtmlAttr(alt);
if (!safeUrl) return alt ? escapeHtml(alt) : '';
return `<img alt="${safeAlt}" src="${escapeHtmlAttr(safeUrl)}" style="max-width:100%;" />`;
});
// 連結
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = sanitizeUrl(url);
const safeText = escapeHtml(text);
if (!safeUrl) return safeText;
return `<a href="${escapeHtmlAttr(safeUrl)}" target="_blank" style="color:var(--mdltx-primary);">${safeText}</a>`;
});
// 引用區塊
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>');
// 表格
const tableRegex = /^\|(.+)\|$/gm;
let tableMatch;
let inTable = false;
let tableRows = [];
const lines = html.split('\n');
const processedLines = [];
for (const line of lines) {
if (/^\|.+\|$/.test(line)) {
if (!inTable) {
inTable = true;
tableRows = [];
}
tableRows.push(line);
} else {
if (inTable) {
processedLines.push(this._processTable(tableRows));
inTable = false;
tableRows = [];
}
processedLines.push(line);
}
}
if (inTable) {
processedLines.push(this._processTable(tableRows));
}
html = processedLines.join('\n');
// 無序列表
html = html.replace(/^[-*+]\s+(?!\[[ x]\])(.+)$/gm, '<li>$1</li>');
// 有序列表
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
// 包裝列表
html = html.replace(/(<li(?:\s+class="[^"]*")?>[\s\S]*?<\/li>)(\n(?:<li))/g, '$1$2');
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, (match) => {
if (match.includes('class="task')) return `<ul style="list-style:none;padding-left:0;">${match}</ul>`;
return `<ul>${match}</ul>`;
});
// 段落
html = html.replace(/\n\n+/g, '</p><p>');
html = html.replace(/([^>])\n([^<])/g, '$1<br />$2');
html = '<p>' + html + '</p>';
// 清理
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>(<(?:h[1-6]|pre|table|ul|ol|blockquote|hr|div))/g, '$1');
html = html.replace(/(<\/(?:h[1-6]|pre|table|ul|ol|blockquote|div)>)<\/p>/g, '$1');
html = html.replace(/<p>(<hr \/>)<\/p>/g, '$1');
// ═══ 還原所有被保護的區塊 ═══
for (let i = 0; i < protectedBlocks.length; i++) {
html = html.split(`\x00PBLOCK${i}\x00`).join(protectedBlocks[i]);
}
return html;
}
_processTable(rows) {
if (rows.length < 2) return rows.join('\n');
const headerRow = rows[0];
const separatorRow = rows[1];
const bodyRows = rows.slice(2);
// 檢查分隔行
const sepCells = separatorRow.slice(1, -1).split('|').map(c => c.trim());
if (!sepCells.every(c => /^:?-+:?$/.test(c))) {
return rows.join('\n');
}
// 解析對齊
const aligns = sepCells.map(c => {
if (c.startsWith(':') && c.endsWith(':')) return 'center';
if (c.endsWith(':')) return 'right';
return 'left';
});
// 建構表格
const headerCells = headerRow.slice(1, -1).split('|').map(c => c.trim());
let html = '<table style="border-collapse:collapse;width:100%;margin:1em 0;"><thead><tr>';
headerCells.forEach((cell, i) => {
html += `<th style="border:1px solid var(--mdltx-border);padding:8px;text-align:${aligns[i] || 'left'};background:var(--mdltx-bg-secondary);">${cell}</th>`;
});
html += '</tr></thead><tbody>';
for (const row of bodyRows) {
const cells = row.slice(1, -1).split('|').map(c => c.trim());
html += '<tr>';
cells.forEach((cell, i) => {
html += `<td style="border:1px solid var(--mdltx-border);padding:8px;text-align:${aligns[i] || 'left'};">${cell}</td>`;
});
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
_updateStats() {
const stats = this.modal?.querySelector('#mdltx-preview-stats');
if (!stats) return;
const chars = this.content.length;
const lines = this.content.split('\n').length;
const words = this.content.trim().split(/\s+/).filter(Boolean).length;
while (stats.firstChild) stats.removeChild(stats.firstChild);
const statItems = [
createElement('span', { className: 'mdltx-preview-stat', textContent: `${t('previewCharCount')}: ${chars.toLocaleString()}` }),
createElement('span', { className: 'mdltx-preview-stat', textContent: `${t('previewLineCount')}: ${lines.toLocaleString()}` }),
createElement('span', { className: 'mdltx-preview-stat', textContent: `${t('previewWordCount')}: ${words.toLocaleString()}` }),
];
// 游標位置(僅在編輯模式中顯示)
if (this._editorRef && (this.mode === 'edit' || this.mode === 'split')) {
const cursorSpan = createElement('span', {
className: 'mdltx-preview-stat',
id: 'mdltx-cursor-pos',
textContent: t('cursorPosition', { line: '1', col: '1' }),
style: { marginLeft: 'auto', fontFamily: 'ui-monospace, monospace', fontSize: '11px' }
});
statItems.push(cursorSpan);
}
stats.append(...statItems);
}
/** 更新游標位置顯示 */
_updateCursorPosition() {
const editor = this._editorRef;
const posEl = this.modal?.querySelector('#mdltx-cursor-pos');
if (!editor || !posEl) return;
const pos = editor.selectionStart;
const textBefore = this.content.substring(0, pos);
const line = textBefore.split('\n').length;
const lastNewline = textBefore.lastIndexOf('\n');
const col = pos - lastNewline;
posEl.textContent = t('cursorPosition', { line: String(line), col: String(col) });
}
async _handleCopy() {
try {
await setClipboardText(this.content);
this.ui.showToast('success', t('previewCopySuccess'), `${this.content.length.toLocaleString()} ${t('previewCharCount')}`);
this.close(true);
} catch (e) { this.ui.showToast('error', t('toastError'), e.message); }
}
async _handleDownload() {
try {
let content = this.content;
// 如果預覽時沒有包含 Frontmatter,下載時需要加上
// 如果預覽時已經包含了 Frontmatter,就直接使用
if (S.get('downloadFrontmatter') && !this.options?.includedFrontmatter) {
const frontmatter = generateFrontmatter();
content = frontmatter + this.content;
}
const filename = generateFilename();
downloadAsFile(content, filename);
this.ui.showToast('success', t('previewDownloadSuccess'), filename);
this.close(true);
} catch (e) { this.ui.showToast('error', t('toastError'), e.message); }
}
getContent() { return this.content; }
}
class TimeoutManager {
constructor() { this._t = new Set(); }
set(fn, delay) { const id = setTimeout(() => { this._t.delete(id); fn(); }, delay); this._t.add(id); return id; }
clear(id) { if (id !== undefined) { clearTimeout(id); this._t.delete(id); } }
clearAll() { for (const id of this._t) clearTimeout(id); this._t.clear(); }
}
function generateNonce() { return Math.random().toString(36).slice(2, 10); }
function makePlaceholder(kind, nonce, id) { return `@@MDLTX${kind}-${nonce}-${id}@@`; }
function calculateTooltipPosition(btnRect, tipW, tipH) {
const margin = 10, vw = window.innerWidth, vh = window.innerHeight;
const top = btnRect.top > tipH + margin ? btnRect.top - tipH - margin
: vh - btnRect.bottom > tipH + margin ? btnRect.bottom + margin
: Math.max(margin, (vh - tipH) / 2);
let left = btnRect.left + (btnRect.width - tipW) / 2;
left = Math.max(margin, Math.min(left, vw - tipW - margin));
return { top, left };
}
// ─────────────────────────────────────────────────────────────
// § UI Manager
// ─────────────────────────────────────────────────────────────
class UIManager {
constructor() {
this.host = null; this.shadow = null; this.root = null;
this.button = null; this.sensor = null; this.tooltip = null; this.menu = null; this.toast = null; this.modal = null;
this.isProcessing = false; this.isDragging = false; this.dragPointerId = null; this.dragOffset = { x: 0, y: 0 };
this.menuOpen = false; this.toastTimeoutId = null;
// 新增模組
this.elementPicker = null;
this.previewModal = null;
this._lastClickTime = 0;
this._doubleClickThreshold = 300;
this._clickTimer = null;
this._buttonWidth = 0; this._buttonHeight = 0;
this._focusTrap = null; this._prevBodyOverflow = '';
this._tm = new TimeoutManager();
this._autoHideTimeoutId = null; this._isButtonHidden = false; this._isMouseOverButton = false;
this._tooltipShowTimeoutId = null;
this._handlers = { docClick: null, docKey: null, themeChange: null, selChange: null };
}
init() {
try {
this._createHost();
this.updateTheme();
if (S.get('showButton')) { this._createButton(); this._createSensor(); this._createTooltip(); this._createMenu(); }
this._createToast();
this._bindGlobal();
this._setupThemeListener();
// 初始化新模組
this.elementPicker = new ElementPicker(this);
this.previewModal = new PreviewModal(this);
this._setupSelectionListener();
this._setupAutoReinject();
} catch (e) { console.error('[mdltx] UI init failed:', e); }
}
_setupThemeListener() {
this._handlers.themeChange = () => { if (S.get('theme') === 'auto') this.updateTheme(); };
try { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this._handlers.themeChange); } catch {}
}
_setupSelectionListener() {
this._handlers.selChange = () => { if (this.menuOpen) this._updateMenuSelState(); };
document.addEventListener('selectionchange', this._handlers.selChange);
}
_startAutoHideTimer() {
if (!S.get('buttonAutoHide')) return;
this._cancelAutoHideTimer();
this._autoHideTimeoutId = this._tm.set(() => {
if (!this._isMouseOverButton && !this.menuOpen && !this.isDragging) this._hideButton();
}, S.get('buttonAutoHideDelay'));
}
_cancelAutoHideTimer() { if (this._autoHideTimeoutId) { this._tm.clear(this._autoHideTimeoutId); this._autoHideTimeoutId = null; } }
_showButton() {
if (!this.button || !this._isButtonHidden) return;
this._isButtonHidden = false;
this.button.classList.remove('auto-hidden');
if (this.sensor) this.sensor.style.pointerEvents = 'none';
}
_hideButton() {
if (!this.button || this._isButtonHidden || this.menuOpen || this.isDragging || this._isMouseOverButton) return;
this._isButtonHidden = true;
this.button.classList.add('auto-hidden');
this._updateSensorPosition();
if (this.sensor) this.sensor.style.pointerEvents = 'auto';
}
_onButtonMouseEnter() { this._isMouseOverButton = true; this._cancelAutoHideTimer(); this._showButton(); }
_onButtonMouseLeave() { this._isMouseOverButton = false; if (S.get('buttonAutoHide') && !this.menuOpen) this._startAutoHideTimer(); }
_updateSensorPosition() {
if (!this.sensor || !this.button) return;
const pos = S.get('buttonPosition'), offX = S.get('buttonOffsetX'), offY = S.get('buttonOffsetY'), btnSize = S.get('buttonSize'), sensorSize = btnSize + 20;
const s = this.sensor.style;
s.width = `${sensorSize}px`; s.height = `${sensorSize}px`; s.top = s.bottom = s.left = s.right = '';
const offset = (btnSize - sensorSize) / 2;
const safeInsets = { b: 'env(safe-area-inset-bottom,0px)', r: 'env(safe-area-inset-right,0px)', l: 'env(safe-area-inset-left,0px)', t: 'env(safe-area-inset-top,0px)' };
const positions = {
'bottom-right': { bottom: `calc(${offY + offset}px + ${safeInsets.b})`, right: `calc(${offX + offset}px + ${safeInsets.r})` },
'bottom-left': { bottom: `calc(${offY + offset}px + ${safeInsets.b})`, left: `calc(${offX + offset}px + ${safeInsets.l})` },
'top-right': { top: `calc(${offY + offset}px + ${safeInsets.t})`, right: `calc(${offX + offset}px + ${safeInsets.r})` },
'top-left': { top: `calc(${offY + offset}px + ${safeInsets.t})`, left: `calc(${offX + offset}px + ${safeInsets.l})` }
};
Object.assign(s, positions[pos] || positions['bottom-right']);
}
_setupAutoReinject() {
this._reinjectObserver = new MutationObserver(() => {
if (this.host && !document.body.contains(this.host)) { console.log('[mdltx] Host removed, reinserting...'); this._reinject(); }
});
this._reinjectObserver.observe(document.body, { childList: true });
this._bodyObserver = new MutationObserver((mutations) => {
for (const m of mutations) for (const node of m.addedNodes) {
if (node.tagName === 'BODY' || node === document.body) {
setTimeout(() => {
this._reinjectObserver?.disconnect();
this._reinjectObserver?.observe(document.body, { childList: true });
if (!document.getElementById('mdltx-ui-host')) { console.log('[mdltx] New body detected, reinserting...'); this._reinject(); }
}, 100);
}
}
});
this._bodyObserver.observe(document.documentElement, { childList: true });
}
_reinject() {
this._cancelAutoHideTimer();
this.host = this.shadow = this.root = this.button = this.sensor = this.tooltip = this.menu = this.toast = null;
this._isButtonHidden = this._isMouseOverButton = false;
this._createHost();
this.updateTheme();
if (S.get('showButton')) { this._createButton(); this._createSensor(); this._createTooltip(); this._createMenu(); }
this._createToast();
}
_createHost() {
this.host = document.createElement('div');
this.host.id = 'mdltx-ui-host';
this.host.setAttribute('data-mdltx-ui', '1');
this.shadow = this.host.attachShadow({ mode: 'closed' });
const style = document.createElement('style'); style.textContent = STYLES;
this.shadow.appendChild(style);
this.root = document.createElement('div'); this.root.className = 'mdltx-root';
this.shadow.appendChild(this.root);
document.body.appendChild(this.host);
}
updateTheme() { if (this.root) this.root.setAttribute('data-theme', getEffectiveTheme()); }
_createButton() {
if (this.button) return;
const size = S.get('buttonSize');
this.button = createElement('button', { className: 'mdltx-btn', type: 'button', role: 'button', tabindex: '0', 'aria-label': t('copyMd'), 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, [
createElement('span', { className: 'mdltx-btn-icon' }, [createIcon('markdown')])
]);
this.button.style.setProperty('--mdltx-btn-size', `${size}px`);
this.button.style.setProperty('--mdltx-btn-opacity', S.get('buttonOpacity'));
this.button.style.setProperty('--mdltx-btn-hover-opacity', S.get('buttonHoverOpacity'));
this.button.style.setProperty('--mdltx-btn-hidden-opacity', S.get('buttonHiddenOpacity'));
this.root.appendChild(this.button);
this._updateButtonPos();
this._bindButton();
requestAnimationFrame(() => requestAnimationFrame(() => {
if (this.button) { this._buttonWidth = this.button.offsetWidth; this._buttonHeight = this.button.offsetHeight; }
}));
if (S.get('buttonAutoHide')) this._startAutoHideTimer();
}
_createSensor() {
if (this.sensor) return;
this.sensor = createElement('div', { className: 'mdltx-sensor', 'aria-hidden': 'true' });
this.sensor.style.pointerEvents = 'none';
this.sensor.addEventListener('mouseenter', () => this._onButtonMouseEnter());
this.root.appendChild(this.sensor);
this._updateSensorPosition();
}
_createTooltip() {
if (this.tooltip) return;
this.tooltip = createElement('div', { className: 'mdltx-tooltip', role: 'tooltip', 'aria-hidden': 'true' });
this.root.appendChild(this.tooltip);
}
_showTooltip() {
if (!this.tooltip || !this.button || this.menuOpen || this.isDragging || this.isProcessing) return;
const actionLabel = getClickActionLabel(true);
let content = t('buttonHint', { action: actionLabel });
const hotkey = getHotkeyString();
if (hotkey) content += '\n' + t('buttonHintHotkey', { hotkey });
this.tooltip.innerHTML = '';
const lines = content.split('\n');
lines.forEach((line, i) => {
if (i > 0 && hotkey && line.includes(hotkey)) {
this.tooltip.appendChild(createElement('span', { className: 'mdltx-tooltip-hotkey', textContent: line }));
} else {
this.tooltip.appendChild(document.createTextNode(line));
if (i < lines.length - 1) this.tooltip.appendChild(document.createElement('br'));
}
});
requestAnimationFrame(() => {
if (!this.button || !this.tooltip) return;
const btnRect = this.button.getBoundingClientRect(), tipRect = this.tooltip.getBoundingClientRect();
const pos = calculateTooltipPosition(btnRect, tipRect.width || 200, tipRect.height || 80);
this.tooltip.style.top = `${pos.top}px`; this.tooltip.style.left = `${pos.left}px`;
this.tooltip.classList.add('show'); this.tooltip.setAttribute('aria-hidden', 'false');
});
}
_hideTooltip() {
if (!this.tooltip) return;
this.tooltip.classList.remove('show'); this.tooltip.setAttribute('aria-hidden', 'true');
if (this._tooltipShowTimeoutId) { this._tm.clear(this._tooltipShowTimeoutId); this._tooltipShowTimeoutId = null; }
}
_updateButtonPos() {
if (!this.button) return;
const pos = S.get('buttonPosition'), offX = S.get('buttonOffsetX'), offY = S.get('buttonOffsetY');
const s = this.button.style; s.top = s.bottom = s.left = s.right = '';
const safeInsets = { b: 'env(safe-area-inset-bottom,0px)', r: 'env(safe-area-inset-right,0px)', l: 'env(safe-area-inset-left,0px)', t: 'env(safe-area-inset-top,0px)' };
const positions = {
'bottom-right': { bottom: `calc(${offY}px + ${safeInsets.b})`, right: `calc(${offX}px + ${safeInsets.r})` },
'bottom-left': { bottom: `calc(${offY}px + ${safeInsets.b})`, left: `calc(${offX}px + ${safeInsets.l})` },
'top-right': { top: `calc(${offY}px + ${safeInsets.t})`, right: `calc(${offX}px + ${safeInsets.r})` },
'top-left': { top: `calc(${offY}px + ${safeInsets.t})`, left: `calc(${offX}px + ${safeInsets.l})` }
};
Object.assign(s, positions[pos] || positions['bottom-right']);
this._updateSensorPosition();
}
_createMenu() {
if (this.menu) return;
this.menu = createElement('div', { className: 'mdltx-menu', id: 'mdltx-menu', role: 'menu', 'aria-label': t('copyMd'), tabindex: '-1' });
this._updateMenuContent();
this.root.appendChild(this.menu);
}
_updateMenuContent() {
if (!this.menu) return;
const hasSel = hasSelection(), noSelMode = S.get('noSelectionMode');
while (this.menu.firstChild) this.menu.removeChild(this.menu.firstChild);
const mkItem = (action, icon, text, disabled = false) => {
const item = createElement('button', { className: 'mdltx-menu-item', role: 'menuitem', type: 'button', tabindex: disabled ? '-1' : '0', dataset: { action } }, [
createElement('span', { className: 'mdltx-menu-item-icon' }, [createIcon(icon)]),
createElement('span', { className: 'mdltx-menu-item-text', textContent: text })
]);
if (disabled) item.setAttribute('disabled', '');
return item;
};
const selItem = mkItem('selection', 'selection', t('copySelection'), !hasSel);
if (!hasSel) selItem.appendChild(createElement('span', { className: 'mdltx-menu-item-hint', textContent: t('noSelection') }));
const artItem = mkItem('article', 'article', t('copyArticle')); if (noSelMode === 'article') artItem.classList.add('active');
const pageItem = mkItem('page', 'page', t('copyPage')); if (noSelMode === 'page') pageItem.classList.add('active');
const menuItems = [selItem, artItem, pageItem,
createElement('div', { className: 'mdltx-menu-divider', role: 'separator' }),
];
// 新增:元素選取
if (S.get('elementPickerEnabled')) {
menuItems.push(mkItem('picker', 'crosshair', t('pickElement')));
}
// 預覽編輯(合併為單一選項)
if (S.get('previewEnabled')) {
menuItems.push(mkItem('preview', 'eye', t('previewEdit')));
}
menuItems.push(
createElement('div', { className: 'mdltx-menu-divider', role: 'separator' }),
mkItem('download', 'download', t('downloadMd')),
createElement('div', { className: 'mdltx-menu-divider', role: 'separator' }),
mkItem('settings', 'settings', t('settings')),
createElement('div', { className: 'mdltx-menu-hint', textContent: this._getHotkeyHint() })
);
menuItems.forEach(el => this.menu.appendChild(el));
this._bindMenu();
}
_updateMenuSelState() {
if (!this.menu) return;
const hasSel = hasSelection(), selItem = this.menu.querySelector('[data-action="selection"]');
if (!selItem) return;
if (hasSel) {
selItem.removeAttribute('disabled'); selItem.setAttribute('tabindex', '0');
const h = selItem.querySelector('.mdltx-menu-item-hint'); if (h) h.remove();
} else {
selItem.setAttribute('disabled', ''); selItem.setAttribute('tabindex', '-1');
if (!selItem.querySelector('.mdltx-menu-item-hint')) selItem.appendChild(createElement('span', { className: 'mdltx-menu-item-hint', textContent: t('noSelection') }));
}
}
_getHotkeyHint() { const hotkey = getHotkeyString(); return hotkey ? `${t('currentHotkey')}: ${hotkey}` : ''; }
_createToast() {
if (this.toast) return;
this.toast = createElement('div', { className: 'mdltx-toast', role: 'status', 'aria-live': 'polite' });
this.root.appendChild(this.toast);
}
showMenu() {
if (!this.button || !this.menu) return;
this._hideTooltip(); this._cancelAutoHideTimer(); this._updateMenuContent();
const m = this.menu, b = this.button, s = m.style;
s.visibility = 'hidden'; s.display = 'block'; m.classList.add('open');
const mr = m.getBoundingClientRect(), br = b.getBoundingClientRect(), pos = S.get('buttonPosition');
s.top = s.bottom = s.left = s.right = ''; m.classList.remove('from-bottom');
if (pos.includes('bottom')) {
if (br.top < mr.height + 16) { s.top = `${br.bottom + 8}px`; m.classList.add('from-bottom'); }
else s.bottom = `${window.innerHeight - br.top + 8}px`;
} else {
if (window.innerHeight - br.bottom < mr.height + 16) s.bottom = `${window.innerHeight - br.top + 8}px`;
else { s.top = `${br.bottom + 8}px`; m.classList.add('from-bottom'); }
}
if (pos.includes('right')) { if (br.right < mr.width) s.left = `${br.left}px`; else s.right = `${window.innerWidth - br.right}px`; }
else { if (window.innerWidth - br.left < mr.width) s.right = `${window.innerWidth - br.right}px`; else s.left = `${br.left}px`; }
s.visibility = ''; s.display = ''; this.menuOpen = true; b.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => { const f = m.querySelector('.mdltx-menu-item:not([disabled])'); if (f) f.focus(); });
}
hideMenu(restoreFocus = true) {
if (this.menu) this.menu.classList.remove('open');
if (this.button) { this.button.setAttribute('aria-expanded', 'false'); if (restoreFocus) this.button.focus(); }
this.menuOpen = false;
if (S.get('buttonAutoHide') && !this._isMouseOverButton) this._startAutoHideTimer();
}
showToast(type, title, detail = '', duration = null) {
if (!this.toast) return;
if (this.toastTimeoutId !== null) { this._tm.clear(this.toastTimeoutId); this.toastTimeoutId = null; }
this.toast.classList.remove('show');
requestAnimationFrame(() => {
while (this.toast.firstChild) this.toast.removeChild(this.toast.firstChild);
this.toast.className = `mdltx-toast ${type}`;
const icons = { success: 'check', error: 'alertCircle', info: 'info' };
const closeBtn = createElement('button', { className: 'mdltx-toast-close', type: 'button', 'aria-label': t('close'), tabindex: '0' }, [createIcon('x')]);
closeBtn.addEventListener('click', () => this.hideToast());
this.toast.append(
createElement('span', { className: 'mdltx-toast-icon' }, [createIcon(icons[type] || 'info')]),
createElement('div', { className: 'mdltx-toast-content' }, [
createElement('div', { className: 'mdltx-toast-title', textContent: title }),
...(detail ? [createElement('div', { className: 'mdltx-toast-detail', textContent: detail })] : [])
]),
closeBtn
);
void this.toast.offsetHeight; this.toast.classList.add('show');
const ms = duration ?? S.get('toastDuration');
if (ms > 0) this.toastTimeoutId = this._tm.set(() => this.hideToast(), ms);
});
}
hideToast() {
if (!this.toast) return;
this.toast.classList.remove('show');
if (this.toastTimeoutId !== null) { this._tm.clear(this.toastTimeoutId); this.toastTimeoutId = null; }
this._tm.set(() => { if (this.toast && !this.toast.classList.contains('show')) { while (this.toast.firstChild) this.toast.removeChild(this.toast.firstChild); } }, 300);
}
setButtonState(state) {
if (!this.button) return;
const iconEl = this.button.querySelector('.mdltx-btn-icon'); if (!iconEl) return;
this.button.classList.remove('processing', 'success', 'error'); while (iconEl.firstChild) iconEl.removeChild(iconEl.firstChild);
const states = {
processing: { cls: 'processing', icon: () => createElement('div', { className: 'mdltx-btn-spinner' }), reset: false },
success: { cls: 'success', icon: () => createIcon('check'), reset: 1500 },
downloaded: { cls: 'success', icon: () => createIcon('check'), reset: 1500 },
error: { cls: 'error', icon: () => createIcon('x'), reset: 2000 }
};
const cfg = states[state];
if (cfg) { this.button.classList.add(cfg.cls); iconEl.appendChild(cfg.icon()); if (cfg.reset) this._tm.set(() => this.setButtonState('default'), cfg.reset); }
else iconEl.appendChild(createIcon('markdown'));
}
showSettings() {
this.hideMenu(false);
if (this.modal) { this.modal.remove(); this.modal = null; }
this._prevBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const settings = S.getAll(), isAdvanced = settings.settingsMode === 'advanced';
const mkCheck = (id, label, checked, advanced = false) => {
const cb = createElement('input', { type: 'checkbox', className: 'mdltx-checkbox', id, tabindex: '0' }); if (checked) cb.checked = true;
const field = createElement('div', { className: `mdltx-field${advanced ? ' hidden' : ''}` }, [
createElement('div', { className: 'mdltx-field-row' }, [createElement('label', { className: 'mdltx-label' }, [cb, createElement('span', { className: 'mdltx-label-text', textContent: label })])])
]);
if (advanced) field.setAttribute('data-advanced', '1');
return field;
};
const mkSelect = (id, label, opts, val, advanced = false) => {
const sel = createElement('select', { className: 'mdltx-select', id, tabindex: '0' });
for (const o of opts) { const opt = createElement('option', { value: o.value, textContent: o.label }); if (o.value === val) opt.selected = true; sel.appendChild(opt); }
const field = createElement('div', { className: `mdltx-field${advanced ? ' hidden' : ''}` }, [
createElement('div', { className: 'mdltx-field-row' }, [createElement('span', { className: 'mdltx-label-text', textContent: label }), sel])
]);
if (advanced) field.setAttribute('data-advanced', '1');
return field;
};
const mkRange = (id, label, val, min, max, step, format = v => `${Math.round(v * 100)}%`, advanced = false) => {
const field = createElement('div', { className: `mdltx-field${advanced ? ' hidden' : ''}` }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: label }),
createElement('div', { className: 'mdltx-range-container' }, [
createElement('input', { type: 'range', className: 'mdltx-range', id, tabindex: '0', min: String(min), max: String(max), step: String(step), value: String(val) }),
createElement('span', { className: 'mdltx-range-value', id: `${id}-value`, textContent: format(val) })
])
])
]);
if (advanced) field.setAttribute('data-advanced', '1');
return field;
};
const mkNum = (id, label, val, min, max, step = 1, advanced = false) => {
const field = createElement('div', { className: `mdltx-field${advanced ? ' hidden' : ''}` }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: label }),
createElement('div', { className: 'mdltx-input-wrapper' }, [
createElement('input', { type: 'number', className: 'mdltx-input', id, tabindex: '0', value: String(val), min: String(min), max: String(max), step: String(step) })
])
])
]);
if (advanced) field.setAttribute('data-advanced', '1');
return field;
};
const mkSection = (title, advanced = false, ...fields) => {
const section = createElement('div', { className: `mdltx-section${advanced ? ' hidden' : ''}` }, [createElement('div', { className: 'mdltx-section-title', textContent: title }), ...fields]);
if (advanced) section.setAttribute('data-advanced', '1');
return section;
};
const overlay = createElement('div', { className: 'mdltx-modal-overlay', tabindex: '-1' });
const modal = createElement('div', { className: 'mdltx-modal', role: 'dialog', 'aria-labelledby': 'mdltx-settings-title', 'aria-modal': 'true' });
const header = createElement('div', { className: 'mdltx-modal-header' }, [createElement('h2', { className: 'mdltx-modal-title', id: 'mdltx-settings-title', textContent: t('settingsTitle') })]);
const closeBtn = createElement('button', { className: 'mdltx-modal-close', type: 'button', 'aria-label': t('close'), tabindex: '0' }, [createIcon('x')]);
header.appendChild(closeBtn);
const modeToggle = createElement('div', { className: 'mdltx-mode-toggle', role: 'tablist' }, [
createElement('button', { className: `mdltx-mode-toggle-btn ${!isAdvanced ? 'active' : ''}`, type: 'button', role: 'tab', tabindex: '0', 'aria-selected': !isAdvanced ? 'true' : 'false', dataset: { mode: 'simple' }, textContent: t('settingsModeSimple') }),
createElement('button', { className: `mdltx-mode-toggle-btn ${isAdvanced ? 'active' : ''}`, type: 'button', role: 'tab', tabindex: '0', 'aria-selected': isAdvanced ? 'true' : 'false', dataset: { mode: 'advanced' }, textContent: t('settingsModeAdvanced') })
]);
const hotkeyField = createElement('div', { className: 'mdltx-field', id: 'hotkey-combo-field', style: { display: settings.hotkeyEnabled ? 'block' : 'none' } });
const hotkeyDisplay = createElement('div', { className: 'mdltx-hotkey-display', id: 'hotkey-display' });
if (settings.hotkeyCtrl) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Ctrl' }));
if (settings.hotkeyAlt) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Alt' }));
if (settings.hotkeyShift) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Shift' }));
hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: settings.hotkeyKey.toUpperCase() }));
hotkeyField.appendChild(createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('hotkeyCombo') }),
createElement('div', { className: 'mdltx-hotkey-input' }, [hotkeyDisplay, createElement('button', { className: 'mdltx-hotkey-record-btn', type: 'button', id: 'hotkey-record-btn', tabindex: '0', textContent: t('pressKey') })])
]));
const autoHideCond = createElement('div', { className: `mdltx-conditional ${settings.buttonAutoHide ? '' : 'hidden'}`, id: 'autohide-conditional' });
autoHideCond.appendChild(mkNum('setting-buttonAutoHideDelay', t('buttonAutoHideDelay'), settings.buttonAutoHideDelay, 300, 10000, 100));
autoHideCond.appendChild(mkRange('setting-buttonHiddenOpacity', t('buttonHiddenOpacity'), settings.buttonHiddenOpacity, 0, 0.5, 0.05));
const offscreenCond = createElement('div', { className: `mdltx-conditional ${settings.strictOffscreen ? '' : 'hidden'}`, id: 'offscreen-conditional' });
offscreenCond.appendChild(mkNum('setting-offscreenMargin', t('offscreenMargin'), settings.offscreenMargin, 0, 500, 10));
const body = createElement('div', { className: 'mdltx-modal-body' }, [
modeToggle,
mkSection(t('generalSettings'), false,
mkCheck('setting-showButton', t('showButton'), settings.showButton),
mkSelect('setting-buttonPosition', t('buttonPosition'), [{ value: 'bottom-right', label: t('bottomRight') }, { value: 'bottom-left', label: t('bottomLeft') }, { value: 'top-right', label: t('topRight') }, { value: 'top-left', label: t('topLeft') }], settings.buttonPosition),
mkRange('setting-buttonSize', t('buttonSize'), settings.buttonSize, 28, 64, 2, v => `${v}px`),
mkRange('setting-buttonOpacity', t('buttonOpacity'), settings.buttonOpacity, 0.3, 1, 0.05),
mkRange('setting-buttonHoverOpacity', t('buttonHoverOpacity'), settings.buttonHoverOpacity, 0.5, 1, 0.05, v => `${Math.round(v * 100)}%`, true),
mkCheck('setting-buttonAutoHide', t('buttonAutoHide'), settings.buttonAutoHide),
autoHideCond,
mkSelect('setting-buttonClickAction', t('buttonClickAction'), [{ value: 'auto', label: t('clickActionAuto') }, { value: 'selection', label: t('clickActionSelection') }, { value: 'article', label: t('clickActionArticle') }, { value: 'page', label: t('clickActionPage') }, { value: 'download', label: t('clickActionDownload') }], settings.buttonClickAction),
mkSelect('setting-theme', t('theme'), [{ value: 'auto', label: t('themeAuto') }, { value: 'light', label: t('themeLight') }, { value: 'dark', label: t('themeDark') }], settings.theme),
mkSelect('setting-language', t('language'), [{ value: 'auto', label: t('langAuto') }, { value: 'zh-TW', label: '繁體中文' }, { value: 'zh-CN', label: '简体中文' }, { value: 'en', label: 'English' }], settings.language)
),
mkSection(t('hotkeySettings'), false, mkCheck('setting-hotkeyEnabled', t('enableHotkey'), settings.hotkeyEnabled), hotkeyField),
mkSection(t('conversionSettings'), false,
mkSelect('setting-noSelectionMode', t('noSelectionMode'), [{ value: 'page', label: t('modePage') }, { value: 'article', label: t('modeArticle') }], settings.noSelectionMode),
mkCheck('setting-absoluteUrls', t('absoluteUrls'), settings.absoluteUrls),
mkCheck('setting-ignoreNav', t('ignoreNav'), settings.ignoreNav, true),
mkCheck('setting-waitMathJax', t('waitMathJax'), settings.waitMathJax),
mkCheck('setting-stripIndent', t('stripIndent'), settings.stripCommonIndentInBlockMath, true),
mkCheck('setting-escapeMarkdownChars', t('escapeMarkdownChars'), settings.escapeMarkdownChars, true),
mkCheck('setting-extractShadowDOM', t('extractShadowDOM'), settings.extractShadowDOM, true),
mkCheck('setting-extractIframes', t('extractIframes'), settings.extractIframes, true)
),
mkSection(t('markdownFormat'), true,
mkSelect('setting-listMarker', t('listMarker'), [{ value: '-', label: '- (dash)' }, { value: '*', label: '* (asterisk)' }, { value: '+', label: '+ (plus)' }], settings.listMarker),
mkSelect('setting-emphasisMarker', t('emphasisMarker'), [{ value: '*', label: '*text*' }, { value: '_', label: '_text_' }], settings.emphasisMarker),
mkSelect('setting-strongMarker', t('strongMarker'), [{ value: '**', label: '**text**' }, { value: '__', label: '__text__' }], settings.strongMarker),
mkSelect('setting-horizontalRule', t('horizontalRule'), [{ value: '---', label: '---' }, { value: '***', label: '***' }, { value: '___', label: '___' }], settings.horizontalRule)
),
mkSection(t('codeBlockSettings'), true,
(() => { const f = mkCheck('setting-enableContentBasedLangDetection', t('enableContentBasedLangDetection'), settings.enableContentBasedLangDetection, true); f.querySelector('label')?.setAttribute('title', t('enableContentBasedLangDetectionTooltip')); return f; })(),
(() => { const f = mkCheck('setting-lmArenaEnhancedDetection', t('lmArenaEnhancedDetection'), settings.lmArenaEnhancedDetection, true); f.querySelector('label')?.setAttribute('title', t('lmArenaEnhancedDetectionTooltip')); return f; })(),
(() => { const f = mkCheck('setting-aiChatPlatformDetection', t('aiChatPlatformDetection'), settings.aiChatPlatformDetection, true); f.querySelector('label')?.setAttribute('title', t('aiChatPlatformDetectionTooltip')); return f; })()
),
mkSection(t('captureSettings'), true,
mkNum('setting-waitBeforeCaptureMs', t('waitBeforeCapture'), settings.waitBeforeCaptureMs, 0, 30000, 100),
mkNum('setting-waitDomIdleMs', t('waitDomIdle'), settings.waitDomIdleMs, 0, 5000, 100)
),
mkSection(t('formatSettings'), true,
mkSelect('setting-strongEmBlockStrategy', t('strongEmBlockStrategy'), [{ value: 'split', label: t('strategySplit') }, { value: 'html', label: t('strategyHtml') }, { value: 'strip', label: t('strategyStrip') }], settings.strongEmBlockStrategy),
mkSelect('setting-complexTableStrategy', t('complexTableStrategy'), [{ value: 'list', label: t('strategyList') }, { value: 'html', label: t('strategyTableHtml') }], settings.complexTableStrategy),
mkSelect('setting-detailsStrategy', t('detailsStrategy'), [{ value: 'preserve', label: t('detailsPreserve') }, { value: 'strict-visual', label: t('detailsStrictVisual') }], settings.detailsStrategy),
mkSelect('setting-unknownEmptyTagStrategy', t('unknownEmptyTagStrategy'), [{ value: 'literal', label: 'Literal (<tag>)' }, { value: 'drop', label: 'Drop' }], settings.unknownEmptyTagStrategy),
mkCheck('setting-mergeAdjacentCodeSpans', t('mergeAdjacentCodeSpans'), settings.mergeAdjacentCodeSpans)
),
mkSection(t('visibilitySettings'), true,
mkSelect('setting-visibilityMode', t('visibilityMode'), [{ value: 'loose', label: t('visibilityLoose') }, { value: 'strict', label: t('visibilityStrict') }, { value: 'dom', label: t('visibilityDom') }], settings.visibilityMode),
mkNum('setting-hiddenScanMaxElements', t('hiddenScanMaxElements'), settings.hiddenScanMaxElements, 100, 50000, 100),
mkCheck('setting-hiddenUntilFoundVisible', t('hiddenUntilFoundVisible'), settings.hiddenUntilFoundVisible),
mkCheck('setting-strictOffscreen', t('strictOffscreen'), settings.strictOffscreen),
offscreenCond
),
mkSection(t('advancedSettings'), true,
mkNum('setting-articleMinChars', t('articleMinChars'), settings.articleMinChars, 100, 10000, 50),
mkNum('setting-articleMinRatio', t('articleMinRatio'), settings.articleMinRatio, 0.1, 1, 0.05),
mkNum('setting-toastDuration', t('toastDuration'), settings.toastDuration, 500, 10000, 100),
(() => { const f = mkCheck('setting-diagnosticLogging', t('diagnosticLogging'), settings.diagnosticLogging, true); f.querySelector('label')?.setAttribute('title', t('diagnosticLoggingHint')); return f; })()
),
// ═══ 下載設定 ═══
mkSection(t('downloadSettings') || 'Download Settings', true,
mkCheck('setting-downloadFrontmatter', t('downloadFrontmatter'), settings.downloadFrontmatter),
(() => {
const frontmatterCond = createElement('div', { className: `mdltx-conditional ${settings.downloadFrontmatter ? '' : 'hidden'}`, id: 'frontmatter-conditional' });
frontmatterCond.append(
mkCheck('setting-frontmatterTitle', t('frontmatterTitle'), settings.frontmatterTitle),
mkCheck('setting-frontmatterDate', t('frontmatterDate'), settings.frontmatterDate),
mkCheck('setting-frontmatterUrl', t('frontmatterUrl'), settings.frontmatterUrl),
mkCheck('setting-frontmatterDescription', t('frontmatterDescription'), settings.frontmatterDescription),
mkCheck('setting-frontmatterAuthor', t('frontmatterAuthor'), settings.frontmatterAuthor),
mkCheck('setting-frontmatterTags', t('frontmatterTags'), settings.frontmatterTags),
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('frontmatterCustom') }),
]),
createElement('textarea', {
className: 'mdltx-input', id: 'setting-frontmatterCustom',
rows: '3', style: { width: '100%', minHeight: '60px', fontFamily: 'monospace' },
placeholder: t('frontmatterCustomHint'),
value: settings.frontmatterCustom || ''
})
])
);
return frontmatterCond;
})(),
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('downloadFilenameTemplate') || 'Filename Template' }),
createElement('input', {
type: 'text', className: 'mdltx-input', id: 'setting-downloadFilenameTemplate',
value: settings.downloadFilenameTemplate, style: { width: '100%', maxWidth: '280px' },
placeholder: '{title}_{date}'
})
]),
createElement('div', { className: 'mdltx-field-hint', textContent: t('downloadFilenameHint') })
])
),
// ═══ 元素選取設定 ═══
mkSection(t('elementPickerSettings'), true,
mkCheck('setting-elementPickerEnabled', t('elementPickerEnabled'), settings.elementPickerEnabled),
(() => {
const pickerCond = createElement('div', { className: `mdltx-conditional ${settings.elementPickerEnabled ? '' : 'hidden'}`, id: 'picker-conditional' });
pickerCond.append(
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('elementPickerHotkey') }),
createElement('input', {
type: 'text', className: 'mdltx-input', id: 'setting-elementPickerHotkey',
value: settings.elementPickerHotkey, maxlength: '1', style: { width: '60px', textAlign: 'center' }
})
])
])
);
return pickerCond;
})(),
mkSelect('setting-buttonDoubleClickAction', t('buttonDoubleClickAction'), [
{ value: 'none', label: t('doubleClickNone') },
{ value: 'picker', label: t('doubleClickPicker') },
{ value: 'preview', label: t('doubleClickPreview') }
], settings.buttonDoubleClickAction)
),
// ═══ 預覽編輯設定 ═══
mkSection(t('previewSettings'), true,
mkCheck('setting-previewEnabled', t('previewEnabled'), settings.previewEnabled),
(() => {
const previewCond = createElement('div', { className: `mdltx-conditional ${settings.previewEnabled ? '' : 'hidden'}`, id: 'preview-conditional' });
previewCond.append(
// 新增:總是預覽選項
mkCheck('setting-previewAlwaysShow', t('previewAlwaysShow'), settings.previewAlwaysShow),
mkCheck('setting-previewSplitView', t('previewSplitView'), settings.previewSplitView),
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('previewHotkey') }),
createElement('input', {
type: 'text', className: 'mdltx-input', id: 'setting-previewHotkey',
value: settings.previewHotkey, maxlength: '1', style: { width: '60px', textAlign: 'center' }
})
])
]),
mkSelect('setting-previewDefaultMode', t('previewDefaultMode'), [
{ value: 'preview', label: t('previewModePreview') },
{ value: 'edit', label: t('previewModeEdit') },
{ value: 'split', label: t('previewSplitView') }
], settings.previewSplitView ? 'split' : settings.previewDefaultMode),
mkRange('setting-previewMaxHeight', t('previewMaxHeight'), settings.previewMaxHeight, 30, 90, 5, v => `${v}vh`),
mkNum('setting-previewFontSize', t('previewFontSize'), settings.previewFontSize, 10, 24, 1)
);
return previewCond;
})()
),
// ═══ 第三方腳本兼容性 ═══
mkSection(t('thirdPartySettings'), true,
mkCheck('setting-thirdPartyCompatibility', t('thirdPartyCompatibility'), settings.thirdPartyCompatibility),
(() => {
const thirdPartyCond = createElement('div', { className: `mdltx-conditional ${settings.thirdPartyCompatibility ? '' : 'hidden'}`, id: 'thirdparty-conditional' });
thirdPartyCond.append(
mkCheck('setting-ignoreCollapsedCodeBlocks', t('ignoreCollapsedCodeBlocks'), settings.ignoreCollapsedCodeBlocks),
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('customExcludeSelectors') }),
]),
createElement('textarea', {
className: 'mdltx-input', id: 'setting-customExcludeSelectors',
rows: '2', style: { width: '100%', minHeight: '40px', fontFamily: 'monospace' },
placeholder: t('customExcludeSelectorsHint'),
value: settings.customExcludeSelectors || ''
})
]),
createElement('div', { className: 'mdltx-field' }, [
createElement('div', { className: 'mdltx-field-row' }, [
createElement('span', { className: 'mdltx-label-text', textContent: t('customIgnoreHiddenSelectors') }),
]),
createElement('textarea', {
className: 'mdltx-input', id: 'setting-customIgnoreHiddenSelectors',
rows: '2', style: { width: '100%', minHeight: '40px', fontFamily: 'monospace' },
placeholder: t('customIgnoreHiddenSelectorsHint'),
value: settings.customIgnoreHiddenSelectors || ''
})
])
);
return thirdPartyCond;
})()
),
]);
const footer = createElement('div', { className: 'mdltx-modal-footer' }, [
createElement('div', { className: 'mdltx-modal-footer-left' }, [
createElement('button', { className: 'mdltx-btn-danger', type: 'button', id: 'settings-reset', tabindex: '0', textContent: t('resetSettings') }),
createElement('button', { className: 'mdltx-btn-icon-sm', type: 'button', id: 'settings-export', tabindex: '0' }, [
createIcon('clipboard', 14), document.createTextNode(' ' + t('exportSettings'))
]),
createElement('button', { className: 'mdltx-btn-icon-sm', type: 'button', id: 'settings-import', tabindex: '0' }, [
createIcon('upload', 14), document.createTextNode(' ' + t('importSettings'))
]),
]),
createElement('span', { className: 'mdltx-modal-footer-hint', textContent: t('settingsHint') }),
createElement('div', { className: 'mdltx-modal-footer-right' }, [
createElement('button', { className: 'mdltx-btn-secondary', type: 'button', id: 'settings-cancel', tabindex: '0', textContent: t('cancel') }),
createElement('button', { className: 'mdltx-btn-primary', type: 'button', id: 'settings-save', tabindex: '0', textContent: t('saveSettings') })
])
]);
modal.append(header, body, footer);
overlay.appendChild(modal);
this.root.appendChild(overlay);
this.modal = overlay;
this._focusTrap = new FocusTrap(modal);
void overlay.offsetHeight;
overlay.classList.add('open');
this._focusTrap.activate();
this._bindSettings(overlay, settings);
this._updateSettingsVisibility(isAdvanced);
}
_updateSettingsVisibility(isAdvanced) {
if (!this.modal) return;
this.modal.querySelectorAll('[data-advanced="1"]').forEach(el => el.classList.toggle('hidden', !isAdvanced));
}
closeSettings() {
if (!this.modal) return;
if (this._focusTrap) { this._focusTrap.deactivate(); this._focusTrap = null; }
document.body.style.overflow = this._prevBodyOverflow; this._prevBodyOverflow = '';
this.modal.classList.remove('open');
this._tm.set(() => { if (this.modal?.parentNode) this.modal.remove(); this.modal = null; }, 200);
}
_bindSettings(overlay, originalSettings) {
let recording = false, hotkeyHandler = null;
const origOpacity = originalSettings.buttonOpacity, origSize = originalSettings.buttonSize, origTheme = getEffectiveTheme();
let tempHotkey = { ctrl: originalSettings.hotkeyCtrl, alt: originalSettings.hotkeyAlt, shift: originalSettings.hotkeyShift, key: originalSettings.hotkeyKey };
let currentMode = originalSettings.settingsMode;
const gv = id => overlay.querySelector(`#${id}`);
const stopRec = () => {
if (!recording) return; recording = false;
const btn = gv('hotkey-record-btn'); if (btn) { btn.classList.remove('recording'); btn.textContent = t('pressKey'); }
if (hotkeyHandler) { document.removeEventListener('keydown', hotkeyHandler, true); hotkeyHandler = null; }
};
const restorePreview = () => {
if (this.button) { this.button.style.setProperty('--mdltx-btn-opacity', origOpacity); this.button.style.setProperty('--mdltx-btn-size', `${origSize}px`); }
if (this.root) this.root.setAttribute('data-theme', origTheme);
};
const close = (restore = true) => { stopRec(); if (restore) restorePreview(); this.closeSettings(); };
const saveSettings = () => {
stopRec();
// ═══ 快捷鍵衝突檢測 ═══
const mainKey = tempHotkey.key.toLowerCase();
const pickerKey = (gv('setting-elementPickerHotkey')?.value || 'e').toLowerCase().slice(0, 1);
const previewKey = (gv('setting-previewHotkey')?.value || 'p').toLowerCase().slice(0, 1);
const hotkeyIsEnabled = gv('setting-hotkeyEnabled')?.checked;
const pickerIsEnabled = gv('setting-elementPickerEnabled')?.checked;
const previewIsEnabled = gv('setting-previewEnabled')?.checked;
const activeKeys = [];
if (hotkeyIsEnabled) activeKeys.push({ key: mainKey, name: t('hotkeyCombo') });
if (pickerIsEnabled) activeKeys.push({ key: pickerKey, name: t('elementPickerHotkey') });
if (previewIsEnabled) activeKeys.push({ key: previewKey, name: t('previewHotkey') });
const seen = new Map();
const conflicts = [];
for (const entry of activeKeys) {
if (seen.has(entry.key)) {
conflicts.push(`${seen.get(entry.key)} / ${entry.name}`);
} else {
seen.set(entry.key, entry.name);
}
}
if (conflicts.length > 0) {
const conflictMsg = detectLanguage().startsWith('zh')
? `以下快捷鍵設定衝突,請修改:\n${conflicts.join('\n')}`
: `Hotkey conflict detected:\n${conflicts.join('\n')}`;
alert(conflictMsg);
return;
}
const valNum = (v, min, max, def) => { const n = parseFloat(v); return isNaN(n) ? def : Math.max(min, Math.min(max, n)); };
const vals = {
showButton: gv('setting-showButton')?.checked,
buttonPosition: gv('setting-buttonPosition')?.value,
buttonSize: valNum(gv('setting-buttonSize')?.value, 28, 64, 42),
buttonOpacity: valNum(gv('setting-buttonOpacity')?.value, 0.3, 1, 0.85),
buttonHoverOpacity: valNum(gv('setting-buttonHoverOpacity')?.value, 0.5, 1, 1),
buttonAutoHide: gv('setting-buttonAutoHide')?.checked,
buttonAutoHideDelay: valNum(gv('setting-buttonAutoHideDelay')?.value, 300, 10000, 1500),
buttonHiddenOpacity: valNum(gv('setting-buttonHiddenOpacity')?.value, 0, 0.5, 0),
buttonClickAction: gv('setting-buttonClickAction')?.value,
theme: gv('setting-theme')?.value, language: gv('setting-language')?.value,
hotkeyEnabled: gv('setting-hotkeyEnabled')?.checked,
hotkeyCtrl: tempHotkey.ctrl, hotkeyAlt: tempHotkey.alt, hotkeyShift: tempHotkey.shift, hotkeyKey: tempHotkey.key,
noSelectionMode: gv('setting-noSelectionMode')?.value,
absoluteUrls: gv('setting-absoluteUrls')?.checked, ignoreNav: gv('setting-ignoreNav')?.checked,
waitMathJax: gv('setting-waitMathJax')?.checked, stripCommonIndentInBlockMath: gv('setting-stripIndent')?.checked,
escapeMarkdownChars: gv('setting-escapeMarkdownChars')?.checked,
extractShadowDOM: gv('setting-extractShadowDOM')?.checked, extractIframes: gv('setting-extractIframes')?.checked,
listMarker: gv('setting-listMarker')?.value, emphasisMarker: gv('setting-emphasisMarker')?.value,
strongMarker: gv('setting-strongMarker')?.value, horizontalRule: gv('setting-horizontalRule')?.value,
waitBeforeCaptureMs: valNum(gv('setting-waitBeforeCaptureMs')?.value, 0, 30000, 0),
waitDomIdleMs: valNum(gv('setting-waitDomIdleMs')?.value, 0, 5000, 0),
visibilityMode: gv('setting-visibilityMode')?.value,
strictOffscreen: gv('setting-strictOffscreen')?.checked,
offscreenMargin: valNum(gv('setting-offscreenMargin')?.value, 0, 500, 100),
articleMinChars: valNum(gv('setting-articleMinChars')?.value, 100, 10000, 600),
articleMinRatio: valNum(gv('setting-articleMinRatio')?.value, 0.1, 1, 0.55),
toastDuration: valNum(gv('setting-toastDuration')?.value, 500, 10000, 2500),
diagnosticLogging: gv('setting-diagnosticLogging')?.checked,
strongEmBlockStrategy: gv('setting-strongEmBlockStrategy')?.value,
complexTableStrategy: gv('setting-complexTableStrategy')?.value,
detailsStrategy: gv('setting-detailsStrategy')?.value,
mergeAdjacentCodeSpans: gv('setting-mergeAdjacentCodeSpans')?.checked,
enableContentBasedLangDetection: gv('setting-enableContentBasedLangDetection')?.checked,
lmArenaEnhancedDetection: gv('setting-lmArenaEnhancedDetection')?.checked,
aiChatPlatformDetection: gv('setting-aiChatPlatformDetection')?.checked,
settingsMode: currentMode,
// Frontmatter
downloadFrontmatter: gv('setting-downloadFrontmatter')?.checked,
frontmatterTitle: gv('setting-frontmatterTitle')?.checked,
frontmatterDate: gv('setting-frontmatterDate')?.checked,
frontmatterUrl: gv('setting-frontmatterUrl')?.checked,
frontmatterDescription: gv('setting-frontmatterDescription')?.checked,
frontmatterAuthor: gv('setting-frontmatterAuthor')?.checked,
frontmatterTags: gv('setting-frontmatterTags')?.checked,
frontmatterCustom: gv('setting-frontmatterCustom')?.value || '',
downloadFilenameTemplate: gv('setting-downloadFilenameTemplate')?.value || '{title}_{date}',
// 元素選取
elementPickerEnabled: gv('setting-elementPickerEnabled')?.checked,
elementPickerHotkey: (gv('setting-elementPickerHotkey')?.value || 'e').toLowerCase().slice(0, 1),
buttonDoubleClickAction: gv('setting-buttonDoubleClickAction')?.value,
// 第三方兼容
thirdPartyCompatibility: gv('setting-thirdPartyCompatibility')?.checked,
ignoreCollapsedCodeBlocks: gv('setting-ignoreCollapsedCodeBlocks')?.checked,
customExcludeSelectors: gv('setting-customExcludeSelectors')?.value || '',
customIgnoreHiddenSelectors: gv('setting-customIgnoreHiddenSelectors')?.value || '',
// 可見性補充
hiddenScanMaxElements: valNum(gv('setting-hiddenScanMaxElements')?.value, 100, 50000, 5000),
hiddenUntilFoundVisible: gv('setting-hiddenUntilFoundVisible')?.checked,
unknownEmptyTagStrategy: gv('setting-unknownEmptyTagStrategy')?.value,
// 預覽設定
previewEnabled: gv('setting-previewEnabled')?.checked,
previewAlwaysShow: gv('setting-previewAlwaysShow')?.checked,
previewSplitView: gv('setting-previewSplitView')?.checked,
previewHotkey: (gv('setting-previewHotkey')?.value || 'p').toLowerCase().slice(0, 1),
previewDefaultMode: gv('setting-previewDefaultMode')?.value,
previewMaxHeight: valNum(gv('setting-previewMaxHeight')?.value, 30, 90, 70),
previewFontSize: valNum(gv('setting-previewFontSize')?.value, 10, 24, 14),
};
for (const [k, v] of Object.entries(vals)) if (v !== undefined) S.set(k, v);
close(false); this.refresh(); this.showToast('success', t('toastSettingsSaved'), t('settingsSaved'));
};
overlay.querySelector('.mdltx-modal-close')?.addEventListener('click', () => close(true));
gv('settings-cancel')?.addEventListener('click', () => close(true));
overlay.addEventListener('click', e => { if (e.target === overlay) close(true); });
overlay.querySelector('.mdltx-modal')?.addEventListener('keydown', e => {
if (recording) return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
else if (e.key === 'Enter') {
const t = e.target, isInput = t.tagName === 'INPUT' && t.type !== 'checkbox', isBtn = t.tagName === 'BUTTON', isSel = t.tagName === 'SELECT';
if (!isInput && !isBtn && !isSel) { e.preventDefault(); e.stopPropagation(); saveSettings(); }
}
});
overlay.querySelectorAll('.mdltx-mode-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode; currentMode = mode;
overlay.querySelectorAll('.mdltx-mode-toggle-btn').forEach(b => { const active = b.dataset.mode === mode; b.classList.toggle('active', active); b.setAttribute('aria-selected', active ? 'true' : 'false'); });
this._updateSettingsVisibility(mode === 'advanced');
});
});
const bindRangePreview = (id, valueFn, onUpdate) => {
const slider = gv(id), valEl = gv(`${id}-value`);
if (slider && valEl) slider.addEventListener('input', () => { const v = parseFloat(slider.value); valEl.textContent = valueFn ? valueFn(v) : `${Math.round(v * 100)}%`; if (onUpdate) onUpdate(v); });
};
bindRangePreview('setting-buttonOpacity', v => `${Math.round(v * 100)}%`, v => { if (this.button) this.button.style.setProperty('--mdltx-btn-opacity', v); });
bindRangePreview('setting-buttonHoverOpacity', v => `${Math.round(v * 100)}%`);
bindRangePreview('setting-buttonHiddenOpacity', v => `${Math.round(v * 100)}%`);
bindRangePreview('setting-buttonSize', v => `${v}px`, v => { if (this.button) this.button.style.setProperty('--mdltx-btn-size', `${v}px`); });
bindRangePreview('setting-previewMaxHeight', v => `${v}vh`);
const themeSelect = gv('setting-theme');
if (themeSelect) themeSelect.addEventListener('change', () => { this.root.setAttribute('data-theme', themeSelect.value === 'auto' ? getEffectiveTheme() : themeSelect.value); });
const setupNumVal = (id, min, max) => {
const inp = gv(id); if (!inp) return;
inp.addEventListener('input', () => { const v = parseFloat(inp.value); inp.classList.remove('valid', 'invalid'); if (inp.value !== '') inp.classList.add(!isNaN(v) && v >= min && v <= max ? 'valid' : 'invalid'); });
inp.addEventListener('blur', () => { const v = parseFloat(inp.value); inp.value = isNaN(v) ? min : Math.max(min, Math.min(max, v)); inp.classList.remove('valid', 'invalid'); });
};
setupNumVal('setting-articleMinChars', 100, 10000); setupNumVal('setting-articleMinRatio', 0.1, 1); setupNumVal('setting-toastDuration', 500, 10000);
setupNumVal('setting-waitBeforeCaptureMs', 0, 30000); setupNumVal('setting-waitDomIdleMs', 0, 5000);
setupNumVal('setting-offscreenMargin', 0, 500); setupNumVal('setting-buttonAutoHideDelay', 300, 10000);
const strictCb = gv('setting-strictOffscreen'), offCond = gv('offscreen-conditional');
if (strictCb && offCond) strictCb.addEventListener('change', () => offCond.classList.toggle('hidden', !strictCb.checked));
const autoHideCb = gv('setting-buttonAutoHide'), autoHideCond = gv('autohide-conditional');
if (autoHideCb && autoHideCond) autoHideCb.addEventListener('change', () => autoHideCond.classList.toggle('hidden', !autoHideCb.checked));
// ═══ 條件區塊 toggle 綁定 ═══
const conditionalBindings = [
['setting-downloadFrontmatter', 'frontmatter-conditional'],
['setting-elementPickerEnabled', 'picker-conditional'],
['setting-previewEnabled', 'preview-conditional'],
['setting-thirdPartyCompatibility', 'thirdparty-conditional'],
];
for (const [cbId, condId] of conditionalBindings) {
const cb = gv(cbId), cond = gv(condId);
if (cb && cond) cb.addEventListener('change', () => cond.classList.toggle('hidden', !cb.checked));
}
const hotkeyDisplay = gv('hotkey-display');
const updateHotkeyDisp = () => {
if (!hotkeyDisplay) return;
while (hotkeyDisplay.firstChild) hotkeyDisplay.removeChild(hotkeyDisplay.firstChild);
if (tempHotkey.ctrl) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Ctrl' }));
if (tempHotkey.alt) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Alt' }));
if (tempHotkey.shift) hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: 'Shift' }));
hotkeyDisplay.appendChild(createElement('span', { className: 'mdltx-kbd', textContent: tempHotkey.key.toUpperCase() }));
};
const ignoredKeys = new Set(['Control', 'Alt', 'Shift', 'Meta', 'CapsLock', 'Tab', 'Escape', 'Enter', 'Backspace', 'Delete', 'Insert', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'PrintScreen', 'ScrollLock', 'Pause', 'ContextMenu', 'NumLock', 'Clear', 'Help']);
const recordBtn = gv('hotkey-record-btn');
if (recordBtn) {
recordBtn.addEventListener('click', () => {
if (recording) { stopRec(); return; }
recording = true; recordBtn.classList.add('recording'); recordBtn.textContent = '...';
hotkeyHandler = e => {
if (!recording || ignoredKeys.has(e.key)) return;
e.preventDefault(); e.stopPropagation();
tempHotkey = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, key: e.key.toLowerCase() };
updateHotkeyDisp(); stopRec();
};
document.addEventListener('keydown', hotkeyHandler, true);
});
}
const hotkeyEnabled = gv('setting-hotkeyEnabled'), hotkeyField = gv('hotkey-combo-field');
if (hotkeyEnabled && hotkeyField) hotkeyEnabled.addEventListener('change', () => { hotkeyField.style.display = hotkeyEnabled.checked ? 'block' : 'none'; if (!hotkeyEnabled.checked) stopRec(); });
gv('settings-reset')?.addEventListener('click', () => { if (confirm(t('confirmReset'))) { S.resetAll(); close(false); this.refresh(); this.showToast('success', t('toastSettingsReset'), t('settingsResetDone')); } });
gv('settings-save')?.addEventListener('click', saveSettings);
// ═══ 匯出設定 ═══
gv('settings-export')?.addEventListener('click', async () => {
try {
const json = exportSettings();
await setClipboardText(json);
this.showToast('success', t('exportSuccess'));
} catch (e) {
this.showToast('error', t('toastError'), e.message);
}
});
// ═══ 匯入設定 ═══
gv('settings-import')?.addEventListener('click', () => {
// 在 modal 內顯示匯入對話框
const modalEl = overlay.querySelector('.mdltx-modal');
if (!modalEl) return;
// 移除已有的匯入對話框(防止重複)
modalEl.querySelector('.mdltx-import-dialog')?.remove();
const importDialog = createElement('div', { className: 'mdltx-import-dialog' });
const dialogInner = createElement('div', { className: 'mdltx-import-dialog-inner' }, [
createElement('div', { className: 'mdltx-import-dialog-title', textContent: t('importSettings') }),
createElement('textarea', {
className: 'mdltx-import-dialog-textarea',
id: 'import-textarea',
placeholder: '{ "showButton": true, ... }',
spellcheck: 'false',
}),
createElement('div', { className: 'mdltx-import-dialog-hint', textContent: t('exportSettings') + ' → ' + t('importSettings') }),
createElement('div', { className: 'mdltx-import-dialog-buttons' }, [
createElement('button', { className: 'mdltx-btn-secondary', type: 'button', id: 'import-cancel', textContent: t('cancel') }),
createElement('button', { className: 'mdltx-btn-primary', type: 'button', id: 'import-confirm', textContent: t('importSettings') }),
]),
]);
importDialog.appendChild(dialogInner);
// 點擊背景關閉
importDialog.addEventListener('click', (e) => {
if (e.target === importDialog) importDialog.remove();
});
// 取消按鈕
dialogInner.querySelector('#import-cancel')?.addEventListener('click', () => importDialog.remove());
// 確認匯入
dialogInner.querySelector('#import-confirm')?.addEventListener('click', () => {
const textarea = dialogInner.querySelector('#import-textarea');
const json = textarea?.value?.trim();
if (!json) { importDialog.remove(); return; }
if (!confirm(t('importConfirm'))) return;
const result = importSettings(json);
importDialog.remove();
if (result.success) {
close(false);
this.refresh();
const details = [t('importSuccessDetail', { count: result.importedCount })];
if (result.ignoredCount > 0) details.push(t('importIgnoredDetail', { count: result.ignoredCount }));
this.showToast('success', t('importSuccess'), details.join('\n'));
} else {
this.showToast('error', t('importFailed'));
}
});
// ESC 關閉
importDialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); importDialog.remove(); }
});
modalEl.style.position = 'relative';
modalEl.appendChild(importDialog);
// 聚焦 textarea
requestAnimationFrame(() => {
dialogInner.querySelector('#import-textarea')?.focus();
});
});
}
_bindButton() {
if (!this.button) return;
this.button.addEventListener('mouseenter', () => { this._onButtonMouseEnter(); if (this._tooltipShowTimeoutId) this._tm.clear(this._tooltipShowTimeoutId); this._tooltipShowTimeoutId = this._tm.set(() => this._showTooltip(), 400); });
this.button.addEventListener('mouseleave', () => { this._onButtonMouseLeave(); this._hideTooltip(); });
// ═══ click 處理 ═══
this.button.addEventListener('click', async e => {
if (this.isDragging || this.isProcessing) return;
e.stopPropagation();
this._hideTooltip();
// 如果菜單打開,直接關閉菜單(不需要等待雙擊檢測)
if (this.menuOpen) {
this.hideMenu();
return;
}
const dblAction = S.get('buttonDoubleClickAction');
// 如果沒有設置雙擊動作,直接執行單擊(無延遲)
if (dblAction === 'none') {
await this._executeClickAction();
return;
}
// 有設置雙擊動作,需要區分單擊和雙擊
if (this._clickTimer !== null) {
// 這是第二次點擊(雙擊)- 取消待執行的單擊
this._tm.clear(this._clickTimer);
this._clickTimer = null;
// 執行雙擊動作
if (dblAction === 'picker') {
this.startElementPicker();
} else if (dblAction === 'preview') {
await this.handlePreview();
}
} else {
// 這是第一次點擊 - 延遲執行,等待可能的雙擊
this._clickTimer = this._tm.set(async () => {
this._clickTimer = null;
await this._executeClickAction();
}, this._doubleClickThreshold);
}
});
this.button.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); this._hideTooltip(); if (this.isDragging || this.isProcessing) return; this.menuOpen ? this.hideMenu() : this.showMenu(); });
this.button.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.button.click(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!this.menuOpen) this.showMenu(); }
else if (e.key === 'Escape' && this.menuOpen) { e.preventDefault(); this.hideMenu(); }
});
this.button.addEventListener('focus', () => { this._onButtonMouseEnter(); this._tooltipShowTimeoutId = this._tm.set(() => this._showTooltip(), 300); });
this.button.addEventListener('blur', () => { this._hideTooltip(); if (!this._isMouseOverButton && S.get('buttonAutoHide')) this._startAutoHideTimer(); });
this.button.addEventListener('pointerdown', e => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
const rect = this.button.getBoundingClientRect();
this.dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const startX = e.clientX, startY = e.clientY; let moved = false;
this.button.setPointerCapture(e.pointerId); this.dragPointerId = e.pointerId;
const onMove = ev => {
if (ev.pointerId !== this.dragPointerId) return;
const dx = ev.clientX - startX, dy = ev.clientY - startY;
if (!moved && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
moved = true; this.isDragging = true; this.button.classList.add('dragging');
this._hideTooltip(); this._cancelAutoHideTimer(); if (this.menuOpen) this.hideMenu(false);
// ═══ 拖曳時取消待執行的單擊 ═══
if (this._clickTimer !== null) {
this._tm.clear(this._clickTimer);
this._clickTimer = null;
}
const x = ev.clientX - this.dragOffset.x, y = ev.clientY - this.dragOffset.y;
const pos = (y < window.innerHeight / 2 ? 'top' : 'bottom') + '-' + (x < window.innerWidth / 2 ? 'left' : 'right');
const btnW = this._buttonWidth || this.button.offsetWidth || 42, btnH = this._buttonHeight || this.button.offsetHeight || 42;
let offX = pos.includes('right') ? window.innerWidth - x - btnW : x, offY = pos.includes('bottom') ? window.innerHeight - y - btnH : y;
offX = Math.max(8, Math.min(offX, window.innerWidth - btnW - 8)); offY = Math.max(8, Math.min(offY, window.innerHeight - btnH - 8));
S.set('buttonPosition', pos); S.set('buttonOffsetX', Math.round(offX)); S.set('buttonOffsetY', Math.round(offY));
this._updateButtonPos();
};
const onUp = ev => {
if (ev.pointerId !== this.dragPointerId) return;
try { this.button.releasePointerCapture(ev.pointerId); } catch {}
this.dragPointerId = null;
this.button.removeEventListener('pointermove', onMove); this.button.removeEventListener('pointerup', onUp); this.button.removeEventListener('pointercancel', onUp);
this.button.classList.remove('dragging');
if (moved) this._tm.set(() => { this.isDragging = false; }, 50);
else this.isDragging = false;
};
this.button.addEventListener('pointermove', onMove); this.button.addEventListener('pointerup', onUp); this.button.addEventListener('pointercancel', onUp);
});
}
// ═══ 執行單擊動作 ═══
async _executeClickAction() {
const action = S.get('buttonClickAction');
switch (action) {
case 'selection': await this.handleCopy('selection'); break;
case 'article': await this.handleCopy('article'); break;
case 'page': await this.handleCopy('page'); break;
case 'download': await this.handleDownload(); break;
default: await this.handleCopy(hasSelection() ? 'selection' : decideModeNoSelection()); break;
}
}
_bindMenu() {
if (!this.menu) return;
this.menu.querySelectorAll('.mdltx-menu-item').forEach(item => {
item.addEventListener('click', async e => {
if (item.hasAttribute('disabled')) { e.preventDefault(); return; }
const action = item.dataset.action; this.hideMenu();
switch (action) {
case 'settings': this.showSettings(); break;
case 'download': await this.handleDownload(); break;
case 'picker': this.startElementPicker(); break;
case 'preview': await this.handlePreview(); break;
default: if (action) await this.handleCopy(action);
}
});
item.addEventListener('keydown', async e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } });
});
this.menu.addEventListener('keydown', e => {
const items = Array.from(this.menu.querySelectorAll('.mdltx-menu-item:not([disabled])')), len = items.length; if (!len) return;
const curIdx = items.indexOf(this.shadow?.activeElement);
const nav = { ArrowDown: (curIdx + 1) % len, ArrowUp: (curIdx - 1 + len) % len, Home: 0, End: len - 1 };
if (e.key in nav) { e.preventDefault(); items[nav[e.key]]?.focus(); }
else if (e.key === 'Escape') { e.preventDefault(); this.hideMenu(); }
else if (e.key === 'Tab') { e.preventDefault(); items[(e.shiftKey ? (curIdx - 1 + len) % len : (curIdx + 1) % len)]?.focus(); }
});
}
_bindGlobal() {
if (this._handlers.docClick) document.removeEventListener('click', this._handlers.docClick);
if (this._handlers.docKey) document.removeEventListener('keydown', this._handlers.docKey);
this._handlers.docClick = e => { if (!this.menuOpen) return; const path = e.composedPath?.() || [e.target]; if (!path.includes(this.host) && !this.host?.contains(e.target)) this.hideMenu(); };
this._handlers.docKey = e => { if (e.key === 'Escape' && this.menuOpen && !this.modal) { e.preventDefault(); this.hideMenu(); } };
document.addEventListener('click', this._handlers.docClick);
document.addEventListener('keydown', this._handlers.docKey);
}
refresh() {
this._cancelAutoHideTimer();
if (this.button) { this.button.remove(); this.button = null; }
if (this.sensor) { this.sensor.remove(); this.sensor = null; }
if (this.tooltip) { this.tooltip.remove(); this.tooltip = null; }
if (this.menu) { this.menu.remove(); this.menu = null; }
this._isButtonHidden = this._isMouseOverButton = false;
this.updateTheme();
if (S.get('showButton')) { this._createButton(); this._createSensor(); this._createTooltip(); this._createMenu(); }
if (this.menu) this._updateMenuContent();
}
async handleCopy(mode) {
if (this.isProcessing) return;
// 如果啟用了「總是預覽」,則先顯示預覽
// 注意:這裡要傳遞 mode 參數,不要讓 handlePreview 自己判斷
if (S.get('previewEnabled') && S.get('previewAlwaysShow')) {
await this.handlePreview(mode);
return;
}
this.isProcessing = true;
this.setButtonState('processing');
try {
const result = await copyMarkdown(mode);
this.setButtonState('success');
const labels = { selection: t('modeSelection'), article: t('modeArticleLabel'), page: t('modePageLabel') };
this.showToast('success', t('toastSuccess'), t('toastSuccessDetail', { mode: labels[result.actualMode] || result.actualMode, count: result.length }));
} catch (e) {
console.error('[mdltx] error:', e);
this.setButtonState('error');
this.showToast('error', t('toastError'), t('toastErrorDetail', { error: e?.message || String(e) }));
}
finally { this.isProcessing = false; }
}
async handleDownload() {
if (this.isProcessing) return;
// 如果啟用了「總是預覽」,則先顯示預覽(包含 Frontmatter)
if (S.get('previewEnabled') && S.get('previewAlwaysShow')) {
await this.handlePreviewForDownload();
return;
}
this.isProcessing = true;
this.setButtonState('processing');
try {
const mode = hasSelection() ? 'selection' : decideModeNoSelection();
const result = await generateMarkdown(mode);
const filename = generateFilename();
const frontmatter = generateFrontmatter();
const content = frontmatter + result.markdown;
downloadAsFile(content, filename);
this.setButtonState('downloaded');
this.showToast('success', t('toastDownloadSuccess'), t('toastDownloadDetail', { filename, count: content.length }));
} catch (e) {
console.error('[mdltx] download error:', e);
this.setButtonState('error');
this.showToast('error', t('toastError'), t('toastErrorDetail', { error: e?.message || String(e) }));
}
finally { this.isProcessing = false; }
}
// 專門用於下載的預覽方法(會包含 Frontmatter)
async handlePreviewForDownload(mode = null) {
if (this.isProcessing || !this.previewModal) return;
try {
this.isProcessing = true;
this.setButtonState('processing');
// 如果沒有指定 mode,才自動判斷
const actualMode = mode || (hasSelection() ? 'selection' : decideModeNoSelection());
const result = await generateMarkdown(actualMode);
// 如果啟用了 Frontmatter,將其加入預覽內容
let previewContent = result.markdown;
if (S.get('downloadFrontmatter')) {
const frontmatter = generateFrontmatter();
previewContent = frontmatter + result.markdown;
}
this.setButtonState('default');
this.isProcessing = false;
await this.previewModal.show(previewContent, {
forDownload: true,
includedFrontmatter: S.get('downloadFrontmatter'),
mode: actualMode
});
} catch (e) {
console.error('[mdltx] Preview for download error:', e);
this.setButtonState('error');
this.showToast('error', t('toastError'), e.message);
this.isProcessing = false;
}
}
// ═══ 元素選取模式(支援預覽)═══
startElementPicker() {
if (!this.elementPicker) return;
this.elementPicker.start(async (element) => {
if (!element) return;
try {
this.setButtonState('processing');
const hiddenTagged = annotateHidden(element);
const codeBlockTagged = annotateCodeBlockLanguages(element);
await waitForMathJax(element);
const mjTagged = annotateMathJax(element);
const clone = element.cloneNode(true);
cleanupAnnotations(mjTagged, 'data-mdltx-tex');
cleanupAnnotations(mjTagged, 'data-mdltx-display');
cleanupAnnotations(hiddenTagged, 'data-mdltx-hidden');
cleanupAnnotations(codeBlockTagged, 'data-mdltx-lang');
cleanupThirdPartyUI(clone);
try { clone.querySelectorAll?.('[data-mdltx-hidden="1"]').forEach(n => n.remove()); } catch {}
const mathMap = replaceMathWithPlaceholders(clone);
const ctx = { depth: 0, escapeText: S.get('escapeMarkdownChars'), inTable: false, baseUri: document.baseURI };
let out = md(clone, ctx);
for (const k of Object.keys(mathMap)) out = out.split(k).join(mathMap[k]);
out = normalizeOutput(out);
// 檢查是否需要先預覽
if (S.get('previewEnabled') && S.get('previewAlwaysShow')) {
this.setButtonState('default');
// 生成元素描述
const tagName = element.tagName.toLowerCase();
const identifier = element.id ? `#${element.id}` :
(element.className && typeof element.className === 'string' ? `.${element.className.split(' ')[0]}` : '');
await this.previewModal.show(out, {
mode: 'element',
elementInfo: `<${tagName}${identifier}>`
});
} else {
// 直接複製
await setClipboardText(out);
this.setButtonState('success');
this.showToast('success', t('pickerCopied'), t('toastSuccessDetail', { mode: element.tagName.toLowerCase(), count: out.length }));
}
} catch (e) {
console.error('[mdltx] Element picker error:', e);
this.setButtonState('error');
this.showToast('error', t('toastError'), e.message);
}
});
}
// ═══ 統一的預覽方法(支援指定模式)═══
async handlePreview(mode = null) {
if (this.isProcessing || !this.previewModal) return;
try {
this.isProcessing = true;
this.setButtonState('processing');
// 如果沒有指定 mode,才自動判斷
const actualMode = mode || (hasSelection() ? 'selection' : decideModeNoSelection());
const result = await generateMarkdown(actualMode);
this.setButtonState('default');
this.isProcessing = false;
await this.previewModal.show(result.markdown, { mode: actualMode });
} catch (e) {
console.error('[mdltx] Preview error:', e);
this.setButtonState('error');
this.showToast('error', t('toastError'), e.message);
this.isProcessing = false;
}
}
destroy() {
this._cancelAutoHideTimer();
// 清理新模組
this.elementPicker?.stop();
this.previewModal?.close(true);
this.elementPicker = null;
this.previewModal = null;
if (this._handlers.docClick) { document.removeEventListener('click', this._handlers.docClick); this._handlers.docClick = null; }
if (this._handlers.docKey) { document.removeEventListener('keydown', this._handlers.docKey); this._handlers.docKey = null; }
if (this._handlers.selChange) { document.removeEventListener('selectionchange', this._handlers.selChange); this._handlers.selChange = null; }
if (this._handlers.themeChange) { try { window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._handlers.themeChange); } catch {} this._handlers.themeChange = null; }
if (this._focusTrap) { this._focusTrap.deactivate(); this._focusTrap = null; }
if (this._tooltipShowTimeoutId) { this._tm.clear(this._tooltipShowTimeoutId); this._tooltipShowTimeoutId = null; }
this._tm.clearAll();
if (this._reinjectObserver) { this._reinjectObserver.disconnect(); this._reinjectObserver = null; }
if (this._bodyObserver) { this._bodyObserver.disconnect(); this._bodyObserver = null; }
if (this._prevBodyOverflow !== undefined) document.body.style.overflow = this._prevBodyOverflow;
if (this.host) { this.host.remove(); this.host = null; }
this.shadow = this.root = this.button = this.sensor = this.tooltip = this.menu = this.toast = this.modal = null;
}
}
// ─────────────────────────────────────────────────────────────
// § 常數集合
// ─────────────────────────────────────────────────────────────
const BLOCK_TAGS = new Set('P,DIV,UL,OL,LI,TABLE,BLOCKQUOTE,PRE,HR,H1,H2,H3,H4,H5,H6,SECTION,ARTICLE,HEADER,FOOTER,NAV,ASIDE,FIGURE,FIGCAPTION,DETAILS,SUMMARY,DL,DT,DD,MAIN,ADDRESS,HGROUP,FORM,FIELDSET,DIALOG'.split(','));
const INLINE_PARENT_TAGS = new Set('A,SPAN,SMALL,LABEL,EM,I,STRONG,B,DEL,S,U,MARK,SUB,SUP,KBD,CITE,Q,ABBR'.split(','));
const INLINEISH_TAGS = new Set([...INLINE_PARENT_TAGS, 'CODE', 'IMG', 'TIME', 'INPUT']);
const MATH_INFRA_TAGS = new Set('MATH,SEMANTICS,ANNOTATION,MROW,MI,MN,MO,MTEXT,MSUP,MSUB,MSUBSUP,MFRAC,MSQRT,MROOT,MTABLE,MTR,MTD,MSTYLE,MPADDED,MUNDER,MOVER,MUNDEROVER,MERROR,MFENCED,MENCLOSE,MSPACE,MPHANTOM,MMULTISCRIPTS,MPRESCRIPTS,NONE,MLABELEDTR'.split(','));
const KNOWN_HTML_TAGS = new Set('A,ABBR,ADDRESS,AREA,ARTICLE,ASIDE,AUDIO,B,BASE,BDI,BDO,BLOCKQUOTE,BODY,BR,BUTTON,CANVAS,CAPTION,CITE,CODE,COL,COLGROUP,DATA,DATALIST,DD,DEL,DETAILS,DFN,DIALOG,DIV,DL,DT,EM,EMBED,FIELDSET,FIGCAPTION,FIGURE,FOOTER,FORM,H1,H2,H3,H4,H5,H6,HEAD,HEADER,HGROUP,HR,HTML,I,IFRAME,IMG,INPUT,INS,KBD,LABEL,LEGEND,LI,LINK,MAIN,MAP,MARK,MENU,META,METER,NAV,NOSCRIPT,OBJECT,OL,OPTGROUP,OPTION,OUTPUT,P,PARAM,PICTURE,PRE,PROGRESS,Q,RP,RT,RUBY,S,SAMP,SCRIPT,SECTION,SELECT,SLOT,SMALL,SOURCE,SPAN,STRONG,STYLE,SUB,SUMMARY,SUP,TABLE,TBODY,TD,TEMPLATE,TEXTAREA,TFOOT,TH,THEAD,TIME,TITLE,TR,TRACK,U,UL,VAR,VIDEO,WBR,MATH,SEMANTICS,ANNOTATION,MROW,MI,MN,MO,MTEXT,MSUP,MSUB,MSUBSUP,MFRAC,MSQRT,MROOT,MTABLE,MTR,MTD,MSTYLE,MPADDED,MUNDER,MOVER,MUNDEROVER,MERROR,MFENCED,MENCLOSE,SVG,G,PATH,RECT,CIRCLE,ELLIPSE,LINE,POLYLINE,POLYGON,TEXT,TSPAN,DEFS,USE,SYMBOL,CLIPPATH,LINEARGRADIENT,RADIALGRADIENT,STOP,FILTER,MASK,PATTERN,MARKER,IMAGE,SWITCH,FOREIGNOBJECT,DESC,METADATA,VIEW'.split(','));
const KNOWN_LANGUAGES = new Set([
'python', 'javascript', 'typescript', 'java', 'c', 'cpp', 'csharp', 'go', 'rust', 'ruby', 'php', 'swift', 'kotlin', 'scala', 'perl', 'lua', 'r', 'matlab', 'julia',
'html', 'css', 'scss', 'sass', 'less', 'stylus', 'json', 'xml', 'yaml', 'toml', 'ini', 'jsx', 'tsx', 'vue', 'svelte', 'astro',
'bash', 'shell', 'sh', 'zsh', 'fish', 'powershell', 'batch', 'cmd',
'sql', 'mysql', 'postgresql', 'sqlite', 'plsql', 'tsql', 'nosql', 'mongodb', 'graphql', 'prisma',
'markdown', 'latex', 'tex', 'restructuredtext', 'asciidoc', 'org',
'dockerfile', 'docker', 'makefile', 'cmake', 'nginx', 'apache', 'terraform', 'ansible', 'kubernetes', 'k8s', 'helm',
'assembly', 'nasm', 'masm', 'wasm', 'wat', 'zig', 'nim', 'crystal', 'vlang', 'd', 'ada', 'fortran', 'cobol', 'pascal', 'delphi',
'haskell', 'clojure', 'fsharp', 'ocaml', 'erlang', 'elixir', 'scheme', 'lisp', 'racket', 'elm', 'purescript',
'dart', 'flutter', 'objectivec', 'groovy',
'diff', 'patch', 'log', 'plaintext', 'text', 'plain', 'raw', 'console', 'output',
'csv', 'tsv', 'ndjson', 'jsonl', 'protobuf', 'thrift', 'avro',
'solidity', 'vyper', 'move', 'cairo', 'wgsl', 'glsl', 'hlsl', 'cuda',
'sparql', 'cypher', 'gremlin', 'xpath', 'xquery',
'hocon', 'dhall', 'jsonnet', 'cue', 'pkl', 'kdl',
'handlebars', 'mustache', 'jinja', 'jinja2', 'twig', 'ejs', 'pug', 'jade', 'haml', 'slim',
'coffeescript', 'livescript', 'reason', 'rescript', 'grain', 'moonscript', 'fennel',
'verilog', 'vhdl', 'systemverilog',
'applescript', 'autohotkey', 'ahk', 'autoit',
'tcl', 'awk', 'sed', 'vim', 'viml', 'vimscript',
'nix', 'starlark', 'bazel', 'buck',
]);
const LANGUAGE_ALIASES = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript', node: 'javascript', nodejs: 'javascript',
ts: 'typescript', mts: 'typescript', cts: 'typescript',
py: 'python', py3: 'python', python3: 'python', ipython: 'python', jupyter: 'python',
rb: 'ruby', rake: 'ruby', gemfile: 'ruby', podfile: 'ruby',
'c++': 'cpp', cxx: 'cpp', cc: 'cpp', hpp: 'cpp', hxx: 'cpp', hh: 'cpp', 'h++': 'cpp',
h: 'c', 'c#': 'csharp', cs: 'csharp', csx: 'csharp', dotnet: 'csharp',
'm': 'objectivec', mm: 'objectivec', 'objective-c': 'objectivec', objc: 'objectivec',
sh: 'bash', zsh: 'bash', ksh: 'bash', csh: 'bash', tcsh: 'bash', bashrc: 'bash', zshrc: 'bash',
ps1: 'powershell', psm1: 'powershell', psd1: 'powershell', pwsh: 'powershell',
bat: 'batch', cmd: 'batch',
htm: 'html', xhtml: 'html', shtml: 'html', css3: 'css',
md: 'markdown', mdown: 'markdown', mkd: 'markdown', mkdown: 'markdown', mdx: 'markdown', rmd: 'markdown',
yml: 'yaml',
tex: 'latex', ltx: 'latex', sty: 'latex', cls: 'latex', bib: 'bibtex', bibtex: 'bibtex',
rst: 'restructuredtext', rest: 'restructuredtext',
adoc: 'asciidoc', asc: 'asciidoc',
jsonc: 'json', json5: 'json', jsonl: 'json', ndjson: 'json', geojson: 'json',
pgsql: 'postgresql', postgres: 'postgresql',
mssql: 'tsql', 't-sql': 'tsql', 'pl/sql': 'plsql',
dockerfile: 'dockerfile', docker: 'dockerfile', containerfile: 'dockerfile',
makefile: 'makefile', make: 'makefile', mak: 'makefile', mk: 'makefile', gnumakefile: 'makefile',
tf: 'terraform', hcl: 'terraform',
hs: 'haskell', lhs: 'haskell',
clj: 'clojure', cljs: 'clojure', cljc: 'clojure', edn: 'clojure',
'f#': 'fsharp', fs: 'fsharp', fsx: 'fsharp', fsi: 'fsharp',
ml: 'ocaml', mli: 'ocaml',
ex: 'elixir', exs: 'elixir', eex: 'elixir', heex: 'elixir', leex: 'elixir',
erl: 'erlang', hrl: 'erlang',
scm: 'scheme', ss: 'scheme', rkt: 'racket',
cl: 'lisp', el: 'lisp', elisp: 'lisp', 'emacs-lisp': 'lisp', 'common-lisp': 'lisp',
rs: 'rust', kt: 'kotlin', kts: 'kotlin', jl: 'julia',
asm: 'assembly', s: 'assembly', v: 'vlang', sol: 'solidity',
text: 'text', txt: 'text', plaintext: 'text', plain: 'text', raw: 'text',
log: 'log', logs: 'log',
console: 'console', terminal: 'console', term: 'console', output: 'output', stdout: 'output',
diff: 'diff', patch: 'diff', csv: 'csv', tsv: 'tsv',
vue: 'vue', svelte: 'svelte', astro: 'astro',
hbs: 'handlebars', j2: 'jinja2', jinja: 'jinja2',
vim: 'vim', vimrc: 'vim', nvim: 'vim',
conf: 'ini', config: 'ini', cfg: 'ini', env: 'ini', properties: 'ini',
'': '', none: '', nolang: '', unknown: '',
};
const AI_CHAT_PLATFORM_HOSTS = new Set([
'claude.ai', 'grok.com', 'lmarena.ai', 'arena.ai', 'chat.openai.com', 'chatgpt.com', 'copilot.microsoft.com',
'gemini.google.com', 'bard.google.com', 'poe.com', 'character.ai', 'you.com', 'perplexity.ai',
'phind.com', 'huggingface.co', 'deepseek.com', 'chat.deepseek.com', 'kimi.moonshot.cn',
'tongyi.aliyun.com', 'chat.mistral.ai', 'pi.ai', 'cohere.com', 'coral.cohere.com',
]);
const MATHML_OP_MAP = {
'±': '\\pm', '∓': '\\mp', '×': '\\times', '÷': '\\div', '·': '\\cdot', '•': '\\bullet',
'≤': '\\le', '≥': '\\ge', '≠': '\\ne', '≈': '\\approx', '≡': '\\equiv', '≪': '\\ll', '≫': '\\gg', '≺': '\\prec', '≻': '\\succ', '≼': '\\preceq', '≽': '\\succeq', '≲': '\\lesssim', '≳': '\\gtrsim', '≶': '\\lessgtr', '≷': '\\gtrless',
'α': '\\alpha', 'β': '\\beta', 'γ': '\\gamma', 'δ': '\\delta', 'ε': '\\epsilon', 'ζ': '\\zeta', 'η': '\\eta', 'θ': '\\theta', 'ι': '\\iota', 'κ': '\\kappa', 'λ': '\\lambda', 'μ': '\\mu', 'ν': '\\nu', 'ξ': '\\xi', 'π': '\\pi', 'ρ': '\\rho', 'σ': '\\sigma', 'τ': '\\tau', 'υ': '\\upsilon', 'φ': '\\phi', 'χ': '\\chi', 'ψ': '\\psi', 'ω': '\\omega', 'ϵ': '\\varepsilon', 'ϑ': '\\vartheta', 'ϕ': '\\varphi', 'ϱ': '\\varrho', 'ς': '\\varsigma', 'ϖ': '\\varpi', 'ϰ': '\\varkappa', 'ϝ': '\\digamma',
'Γ': '\\Gamma', 'Δ': '\\Delta', 'Θ': '\\Theta', 'Λ': '\\Lambda', 'Ξ': '\\Xi', 'Π': '\\Pi', 'Σ': '\\Sigma', 'Υ': '\\Upsilon', 'Φ': '\\Phi', 'Ψ': '\\Psi', 'Ω': '\\Omega',
'∈': '\\in', '∉': '\\notin', '∋': '\\ni', '⊂': '\\subset', '⊃': '\\supset', '⊆': '\\subseteq', '⊇': '\\supseteq', '⊊': '\\subsetneq', '⊋': '\\supsetneq', '∪': '\\cup', '∩': '\\cap', '⊔': '\\sqcup', '⊓': '\\sqcap', '∧': '\\land', '∨': '\\lor', '¬': '\\neg', '⊕': '\\oplus', '⊗': '\\otimes', '⊖': '\\ominus', '⊘': '\\oslash', '⊙': '\\odot', '∅': '\\emptyset', '∀': '\\forall', '∃': '\\exists', '∄': '\\nexists', '⊢': '\\vdash', '⊣': '\\dashv', '⊨': '\\models', '⊩': '\\Vdash',
'→': '\\to', '←': '\\leftarrow', '↔': '\\leftrightarrow', '⇒': '\\Rightarrow', '⇐': '\\Leftarrow', '⇔': '\\Leftrightarrow', '↑': '\\uparrow', '↓': '\\downarrow', '↕': '\\updownarrow', '⇑': '\\Uparrow', '⇓': '\\Downarrow', '⇕': '\\Updownarrow', '↦': '\\mapsto', '↪': '\\hookrightarrow', '↩': '\\hookleftarrow', '↗': '\\nearrow', '↘': '\\searrow', '↙': '\\swarrow', '↖': '\\nwarrow', '⟶': '\\longrightarrow', '⟵': '\\longleftarrow', '⟷': '\\longleftrightarrow', '⟹': '\\Longrightarrow', '⟸': '\\Longleftarrow', '⟺': '\\Longleftrightarrow', '↠': '\\twoheadrightarrow', '↣': '\\rightarrowtail', '⇀': '\\rightharpoonup', '⇁': '\\rightharpoondown', '↼': '\\leftharpoonup', '↽': '\\leftharpoondown',
'∞': '\\infty', '∂': '\\partial', '∇': '\\nabla', '∑': '\\sum', '∏': '\\prod', '∐': '\\coprod', '∫': '\\int', '∮': '\\oint', '∬': '\\iint', '∭': '\\iiint', '√': '\\sqrt', '∝': '\\propto', '∼': '\\sim', '≃': '\\simeq', '≅': '\\cong', '⊥': '\\perp', '∥': '\\parallel', '∠': '\\angle', '∡': '\\measuredangle', '°': '^\\circ', '′': "'", '″': "''", '‴': "'''", '…': '\\ldots', '⋯': '\\cdots', '⋮': '\\vdots', '⋱': '\\ddots', '⊤': '\\top', '★': '\\star', '⋆': '\\star', '†': '\\dagger', '‡': '\\ddagger', 'ℓ': '\\ell', 'ℏ': '\\hbar', 'ℑ': '\\Im', 'ℜ': '\\Re', 'ℵ': '\\aleph', 'ℶ': '\\beth', '⌈': '\\lceil', '⌉': '\\rceil', '⌊': '\\lfloor', '⌋': '\\rfloor', '⟨': '\\langle', '⟩': '\\rangle', '∘': '\\circ', '∙': '\\bullet', '⋄': '\\diamond', '△': '\\triangle', '▽': '\\triangledown', '⊲': '\\triangleleft', '⊳': '\\triangleright', '⋈': '\\bowtie', '⊎': '\\uplus', '⊍': '\\cupdot',
'ℕ': '\\mathbb{N}', 'ℤ': '\\mathbb{Z}', 'ℚ': '\\mathbb{Q}', 'ℝ': '\\mathbb{R}', 'ℂ': '\\mathbb{C}', 'ℍ': '\\mathbb{H}', 'ℙ': '\\mathbb{P}',
'≜': '\\triangleq', '≝': '\\triangleq', '≐': '\\doteq', '≑': '\\doteqdot', '∴': '\\therefore', '∵': '\\because', '⊻': '\\veebar', '⊼': '\\barwedge', '⋅': '\\cdot',
'⁺': '^+', '⁻': '^-', '⁰': '^0', '¹': '^1', '²': '^2', '³': '^3', '⁴': '^4', '⁵': '^5', '⁶': '^6', '⁷': '^7', '⁸': '^8', '⁹': '^9',
'₀': '_0', '₁': '_1', '₂': '_2', '₃': '_3', '₄': '_4', '₅': '_5', '₆': '_6', '₇': '_7', '₈': '_8', '₉': '_9',
'ₐ': '_a', 'ₑ': '_e', 'ᵢ': '_i', 'ⱼ': '_j', 'ₖ': '_k', 'ₗ': '_l', 'ₘ': '_m', 'ₙ': '_n', 'ₒ': '_o', 'ₚ': '_p', 'ᵣ': '_r', 'ₛ': '_s', 'ₜ': '_t', 'ᵤ': '_u', 'ᵥ': '_v', 'ₓ': '_x',
};
// ─────────────────────────────────────────────────────────────
// § 可見性判斷
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────
// § 第三方腳本兼容性
// ─────────────────────────────────────────────────────────────
/**
* 已知的第三方腳本 UI 選擇器(會被自動排除)
*/
const THIRD_PARTY_UI_SELECTORS = [
// Collapsible Code Blocks (LMArena)
'.cbc-footer', '.cbc-footer-fixed', '.cbc-state-hint', '.cbc-hold-tooltip',
'#cbc-toolbar-toggle', '.cbc-dual-btn', '#cbc-styles',
// 通用 userscript 模式
'[class*="userscript-"]:not(pre):not(code)', '[id*="userscript-"]', '[data-userscript]',
];
/**
* 已知的「假隱藏」選擇器(雖然 CSS 隱藏但內容應保留)
*/
const KNOWN_FALSE_HIDDEN_SELECTORS = [
'.cbc-container.cbc-collapsed', '.cbc-collapsed',
];
/**
* 檢測頁面上的第三方腳本
*/
function detectThirdPartyScripts() {
const detected = { collapsibleCodeBlocks: false, others: [] };
try {
if (document.getElementById('cbc-styles') || document.querySelector('.cbc-footer') ||
document.querySelector('#cbc-toolbar-toggle') || typeof window.CodeBlockCollapse !== 'undefined') {
detected.collapsibleCodeBlocks = true;
}
const userscriptElements = document.querySelectorAll('[class*="userscript"],[id*="userscript"],[data-userscript]');
if (userscriptElements.length > 0) detected.others.push('unknown-userscript');
} catch (e) { console.warn('[mdltx] Error detecting third-party scripts:', e); }
return detected;
}
/**
* 判斷元素是否為第三方腳本的 UI
*/
function isThirdPartyUI(el) {
if (!el || el.nodeType !== 1 || !S.get('thirdPartyCompatibility')) return false;
try {
const className = el.className || '';
if (typeof className === 'string') {
// Collapsible Code Blocks UI(但保留 .cbc-container 內容容器)
if (/\bcbc-/.test(className) && !/\bcbc-container\b/.test(className)) return true;
if (/userscript/.test(className)) return true;
}
const id = el.id || '';
if (/^(cbc-|userscript)/i.test(id)) return true;
for (const selector of THIRD_PARTY_UI_SELECTORS) {
try { if (el.matches?.(selector)) return true; } catch {}
}
const customExclude = S.get('customExcludeSelectors');
if (customExclude) {
for (const selector of customExclude.split('\n').map(s => s.trim()).filter(Boolean)) {
try { if (el.matches?.(selector)) return true; } catch {}
}
}
} catch (e) { console.warn('[mdltx] Error checking third-party UI:', e); }
return false;
}
/**
* 判斷元素是否被第三方腳本「假隱藏」
*/
function isFalseHiddenByThirdParty(el) {
if (!el || el.nodeType !== 1 || !S.get('thirdPartyCompatibility')) return false;
try {
const className = el.className || '';
if (S.get('ignoreCollapsedCodeBlocks')) {
if (typeof className === 'string' && /\bcbc-collapsed\b/.test(className)) return true;
if (el.closest?.('.cbc-collapsed')) return true;
}
for (const selector of KNOWN_FALSE_HIDDEN_SELECTORS) {
try { if (el.matches?.(selector) || el.closest?.(selector)) return true; } catch {}
}
const customIgnore = S.get('customIgnoreHiddenSelectors');
if (customIgnore) {
for (const selector of customIgnore.split('\n').map(s => s.trim()).filter(Boolean)) {
try { if (el.matches?.(selector) || el.closest?.(selector)) return true; } catch {}
}
}
} catch (e) { console.warn('[mdltx] Error checking false-hidden:', e); }
return false;
}
/**
* 抓取前臨時展開被折疊的內容
*/
function prepareForCapture(scope) {
const restoreActions = [];
if (!S.get('thirdPartyCompatibility') || !S.get('ignoreCollapsedCodeBlocks')) return restoreActions;
try {
const collapsedContainers = (scope || document.body).querySelectorAll('.cbc-container.cbc-collapsed');
collapsedContainers.forEach(el => {
el.classList.remove('cbc-collapsed');
restoreActions.push(() => el.classList.add('cbc-collapsed'));
});
const collapsedHeaders = (scope || document.body).querySelectorAll('.cbc-header-collapsed');
collapsedHeaders.forEach(el => {
el.classList.remove('cbc-header-collapsed');
restoreActions.push(() => el.classList.add('cbc-header-collapsed'));
});
if (collapsedContainers.length > 0) {
console.log(`[mdltx] Temporarily expanded ${collapsedContainers.length} collapsed code block(s)`);
}
} catch (e) { console.warn('[mdltx] Error preparing for capture:', e); }
return restoreActions;
}
function restoreAfterCapture(restoreActions) {
for (const action of restoreActions) { try { action(); } catch {} }
}
/**
* 清理 clone 中的第三方 UI 元素
*/
function cleanupThirdPartyUI(clonedRoot) {
if (!S.get('thirdPartyCompatibility')) return;
try {
const allSelectors = [...THIRD_PARTY_UI_SELECTORS];
const custom = S.get('customExcludeSelectors');
if (custom) allSelectors.push(...custom.split('\n').map(s => s.trim()).filter(Boolean));
let removedCount = 0;
for (const selector of allSelectors) {
try { clonedRoot.querySelectorAll?.(selector).forEach(el => { el.remove(); removedCount++; }); } catch {}
}
if (removedCount > 0) console.log(`[mdltx] Removed ${removedCount} third-party UI element(s)`);
} catch (e) { console.warn('[mdltx] Error cleaning up third-party UI:', e); }
}
function isOurUI(el) { try { return el?.getAttribute?.('data-mdltx-ui') === '1' || el?.id === 'mdltx-ui-host' || !!el?.closest?.('[data-mdltx-ui="1"]'); } catch { return false; } }
function isMathInfra(el) { return el?.nodeType === 1 && !!(el.closest?.('.katex,.katex-display,.katex-mathml,mjx-container,.MathJax,span.MathJax') || MATH_INFRA_TAGS.has(el.tagName)); }
function isNavLike(el) { return el?.nodeType === 1 && (/^(NAV|HEADER|FOOTER|ASIDE)$/.test(el.tagName) || /^(navigation|banner|contentinfo|complementary)$/.test((el.getAttribute?.('role') || '').toLowerCase())); }
function isHiddenInClone(node) { try { return node?.getAttribute?.('data-mdltx-hidden') === '1' || !!node?.closest?.('[data-mdltx-hidden="1"]'); } catch { return false; } }
function isElementHiddenByAttribute(el) {
if (!el || el.nodeType !== 1) return false;
const hiddenAttr = el.getAttribute?.('hidden');
if (hiddenAttr === 'until-found') { const mode = S.get('visibilityMode'); return !(S.get('hiddenUntilFoundVisible') && (mode === 'dom' || mode === 'loose')); }
return el.hidden === true || hiddenAttr !== null;
}
function isInClosedDetails(el) {
if (!el || el.nodeType !== 1 || S.get('detailsStrategy') !== 'strict-visual') return false;
let cur = el.parentElement;
while (cur) {
if (cur.tagName === 'DETAILS') { if (cur.hasAttribute('open')) { cur = cur.parentElement; continue; } return !(el.tagName === 'SUMMARY' && el.parentElement === cur); }
cur = cur.parentElement;
}
return false;
}
function isOffscreen(el) {
if (!el || el.nodeType !== 1 || !S.get('strictOffscreen')) return false;
try {
const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false;
const margin = S.get('offscreenMargin');
return rect.bottom <= -margin || rect.right <= -margin || rect.top >= window.innerHeight + margin || rect.left >= window.innerWidth + margin;
} catch { return false; }
}
function isVisuallyHidden(el) {
if (!el || el.nodeType !== 1) return false;
const mode = S.get('visibilityMode'); if (mode === 'dom') return false;
try {
const cs = window.getComputedStyle?.(el); if (!cs) return false;
if (cs.display === 'none') {
if (isFalseHiddenByThirdParty(el)) return false;
return true;
}
if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return true;
if (mode === 'strict') {
if (cs.opacity === '0' || cs.contentVisibility === 'hidden') return true;
if (el.tagName === 'DIALOG' && !el.hasAttribute('open')) return true;
if (el.hasAttribute('popover') && !el.matches?.(':popover-open')) return true;
if (el.hasAttribute('inert')) return true;
if (cs.clip === 'rect(0px, 0px, 0px, 0px)' || cs.clipPath === 'inset(100%)') return true;
if (parseFloat(cs.width) < 1 && parseFloat(cs.height) < 1 && cs.overflow === 'hidden') return true;
return isOffscreen(el);
}
} catch {}
return false;
}
function shouldHideElement(el) {
if (!el || el.nodeType !== 1 || isOurUI(el) || isMathInfra(el)) return false;
// 第三方腳本 UI 應該被隱藏(不出現在輸出中)
if (isThirdPartyUI(el)) return true;
// 檢查是否為「假隱藏」(第三方腳本折疊但內容應保留)
if (isFalseHiddenByThirdParty(el)) return false;
// AI 聊天平台特殊處理:使用最寬鬆的隱藏檢測
if (isAIChatPlatform()) {
if (isElementHiddenByAttribute(el)) return true;
try {
const cs = window.getComputedStyle?.(el);
if (cs?.display === 'none' && !isFalseHiddenByThirdParty(el)) return true;
} catch {}
return false;
}
if (isElementHiddenByAttribute(el)) return true;
const mode = S.get('visibilityMode'); if (mode === 'dom') return false;
if ((el.getAttribute?.('aria-hidden') || '').toLowerCase() === 'true') {
if (isFalseHiddenByThirdParty(el)) return false;
return true;
}
if (mode === 'strict' && isInClosedDetails(el)) return true;
return isVisuallyHidden(el);
}
function annotateHidden(scope) {
const tagged = [], max = S.get('hiddenScanMaxElements');
try {
const walker = document.createTreeWalker(scope || document.body, NodeFilter.SHOW_ELEMENT); let n = 0;
while (walker.nextNode() && ++n <= max) {
const el = walker.currentNode;
if (isOurUI(el) || isMathInfra(el) || el.tagName === 'DETAILS' || el.tagName === 'SUMMARY') continue;
if (shouldHideElement(el)) { el.setAttribute('data-mdltx-hidden', '1'); tagged.push(el); }
}
} catch (e) { console.warn('[mdltx] annotateHidden error:', e); }
return tagged;
}
function annotateFormatBoundaries(scope) {
const tagged = [];
try {
(scope || document.body).querySelectorAll('strong *, b *, em *, i *, del *, s *').forEach(el => {
if (el.nodeType !== 1) return;
try { const style = window.getComputedStyle(el); if (/^(block|flex|grid|table)$/.test(style.display)) { el.setAttribute('data-mdltx-block', '1'); tagged.push(el); } } catch {}
});
} catch (e) { console.warn('[mdltx] annotateFormatBoundaries error:', e); }
return tagged;
}
function cleanupAnnotations(nodes, attr) { for (const n of nodes || []) try { n.removeAttribute(attr); } catch {} }
// ─────────────────────────────────────────────────────────────
// § iframe / Shadow DOM
// ─────────────────────────────────────────────────────────────
function annotateIframes(scope) {
if (!S.get('extractIframes')) return [];
const tagged = [];
try {
(scope || document.body).querySelectorAll('iframe').forEach(iframe => {
try {
const doc = iframe.contentDocument; if (!doc?.body) return;
const content = md(doc.body, normalizeCtx({ baseUri: doc.baseURI || iframe.src || document.baseURI, escapeText: S.get('escapeMarkdownChars') }));
if (content.trim()) { iframe.setAttribute('data-mdltx-iframe-md', content.trim()); tagged.push(iframe); }
} catch {}
});
} catch (e) { console.warn('[mdltx] annotateIframes error:', e); }
return tagged;
}
function extractShadowContent(el, ctx) {
if (!S.get('extractShadowDOM') || !el.shadowRoot) return '';
try { let r = ''; for (const child of Array.from(el.shadowRoot.childNodes)) r += md(child, ctx); return r; } catch { return ''; }
}
// ─────────────────────────────────────────────────────────────
// § MathJax / LaTeX / MathML
// ─────────────────────────────────────────────────────────────
function getPageMathJax() { try { return (typeof unsafeWindow !== 'undefined' && unsafeWindow.MathJax) || window.MathJax || null; } catch { return window.MathJax || null; } }
function getMathItemsWithin(scope) {
try {
const doc = getPageMathJax()?.startup?.document; if (!doc) return [];
return typeof doc.getMathItemsWithin === 'function' ? (doc.getMathItemsWithin(scope || document.body) || []) : (Array.isArray(doc.math) ? doc.math : []);
} catch { return []; }
}
async function waitForMathJax(scope) {
if (!S.get('waitMathJax')) return;
const MJ = getPageMathJax(); if (!MJ) return;
try {
for (let i = 0; i < 10; i++) {
try { if (MJ.startup?.promise) await MJ.startup.promise; } catch {}
try { if (typeof MJ.typesetPromise === 'function') { try { await MJ.typesetPromise(scope ? [scope] : undefined); } catch { await MJ.typesetPromise(); } } } catch {}
if ((getMathItemsWithin(scope) || []).length > 0) return;
if (document.querySelector('mjx-container,.MathJax') && i >= 1) return;
await new Promise(r => setTimeout(r, 200));
}
} catch (e) { console.warn('[mdltx] waitForMathJax error:', e); }
}
function annotateMathJax(scope) {
const added = [];
try {
for (const it of getMathItemsWithin(scope)) {
const root = it?.typesetRoot; if (!root?.setAttribute) continue;
if (scope && scope !== document.body && !scope.contains?.(root)) continue;
const tex = it.math; if (typeof tex !== 'string' || !tex.trim() || root.hasAttribute('data-mdltx-tex')) continue;
root.setAttribute('data-mdltx-tex', tex);
root.setAttribute('data-mdltx-display', it.display ? 'block' : 'inline');
added.push(root);
}
} catch (e) { console.warn('[mdltx] annotateMathJax error:', e); }
return added;
}
function extractTex(el) {
if (!el) return '';
try {
const dt = el.getAttribute?.('data-mdltx-tex'); if (dt) return dt.trim();
const alttext = el.getAttribute?.('alttext'); if (alttext) return alttext.trim();
const annSelectors = ['annotation[encoding="application/x-tex"]', 'annotation[encoding="application/x-latex"]', 'annotation[encoding*="tex"]', 'annotation[encoding*="TeX"]', 'annotation[encoding*="latex"]', 'annotation[encoding*="LaTeX"]', 'annotation:not([encoding])'];
for (const sel of annSelectors) { const ann = el.querySelector?.(sel); if (ann?.textContent?.trim()) return ann.textContent.trim(); }
const ds = el.dataset || {};
if (ds.latex) return ds.latex.trim(); if (ds.tex) return ds.tex.trim(); if (ds.formula) return ds.formula.trim();
if (el.tagName === 'SCRIPT' && /^math\/tex/i.test(el.type || '')) return (el.textContent || '').trim();
const sc = el.querySelector?.('script[type^="math/tex"]'); if (sc?.textContent) return sc.textContent.trim();
const mathml = el.querySelector?.('.katex-mathml annotation'); if (mathml?.textContent) return mathml.textContent.trim();
const title = el.getAttribute?.('title'); if (title && /^[\\{}\[\]a-zA-Z0-9_^+\-*/=<>()., ]+$/.test(title)) return title.trim();
} catch {}
return '';
}
function isDisplayMath(el, tex) {
tex = String(tex || '');
try {
const disp = el.getAttribute?.('data-mdltx-display'); if (disp) return disp === 'block';
if (el.classList?.contains('katex-display') || el.closest?.('.katex-display,.MathJax_Display,.math-display,[data-math-display="block"]')) return true;
if (el.tagName === 'MJX-CONTAINER') { const da = el.getAttribute?.('display'); if (da === 'true' || da === 'block') return true; }
if (el.tagName === 'MATH') { const disp = el.getAttribute?.('display'); if (disp === 'block') return true; }
} catch {}
if (/\\begin\{(align|aligned|equation|gather|multline|cases|array|matrix|bmatrix|pmatrix|vmatrix|Bmatrix|Vmatrix|split|eqnarray)\*?\}/.test(tex)) return true;
return tex.includes('\n') && tex.length > 20;
}
function stripCommonIndent(tex) {
try {
let lines = String(tex || '').replace(/\r\n/g, '\n').split('\n');
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
let min = null;
for (const l of lines) if (l.trim()) { const n = l.match(/^[ \t]*/)[0].length; min = min === null ? n : Math.min(min, n); }
return min > 0 ? lines.map(l => l.slice(min)).join('\n') : lines.join('\n');
} catch { return tex; }
}
function processMathML(mathEl) {
try {
const existingTex = extractTex(mathEl);
if (existingTex) {
const isBlock = mathEl.getAttribute('display') === 'block' || mathEl.closest?.('[display="block"]');
return isBlock ? `\n\n$$\n${existingTex}\n$$\n\n` : `$${existingTex}$`;
}
const getChildren = node => Array.from(node?.childNodes || []).filter(c => c.nodeType === 1);
const collect = node => {
if (!node) return '';
if (node.nodeType === 3) return (node.nodeValue || '').trim();
if (node.nodeType !== 1) return '';
const tag = node.tagName?.toLowerCase() || '', ch = getChildren(node), txt = () => (node.textContent || '').trim();
switch (tag) {
case 'msup': return ch.length >= 2 ? `{${collect(ch[0])}}^{${collect(ch[1])}}` : txt();
case 'msub': return ch.length >= 2 ? `{${collect(ch[0])}}_{${collect(ch[1])}}` : txt();
case 'msubsup': return ch.length >= 3 ? `{${collect(ch[0])}}_{${collect(ch[1])}}^{${collect(ch[2])}}` : txt();
case 'mfrac': return ch.length >= 2 ? `\\frac{${collect(ch[0])}}{${collect(ch[1])}}` : txt();
case 'msqrt': return `\\sqrt{${ch.map(collect).join('')}}`;
case 'mroot': return ch.length >= 2 ? `\\sqrt[${collect(ch[1])}]{${collect(ch[0])}}` : txt();
case 'mover': {
if (ch.length < 2) return txt();
const base = collect(ch[0]), over = collect(ch[1]);
if (over === '→' || over === '\\to' || over === '⟶' || over === '⃗') return `\\vec{${base}}`;
if (over === '¯' || over === '−' || over === '-' || over === '‾' || over === '̄') return `\\overline{${base}}`;
if (over === '^' || over === '̂' || over === '∧' || over === 'ˆ') return `\\hat{${base}}`;
if (over === '~' || over === '̃' || over === '˜') return `\\tilde{${base}}`;
if (over === '˙' || over === '.') return `\\dot{${base}}`;
if (over === '¨' || over === '..') return `\\ddot{${base}}`;
if (over === '⏞') return `\\overbrace{${base}}`;
if (over === '⌢') return `\\widehat{${base}}`;
return `\\overset{${over}}{${base}}`;
}
case 'munder': {
if (ch.length < 2) return txt();
const base = collect(ch[0]), under = collect(ch[1]);
if (under === '_' || under === '̲' || under === '‾') return `\\underline{${base}}`;
if (under === '⏟') return `\\underbrace{${base}}`;
return `\\underset{${under}}{${base}}`;
}
case 'munderover': {
if (ch.length < 3) return txt();
const base = collect(ch[0]), under = collect(ch[1]), over = collect(ch[2]), baseText = txt().trim();
if (['∑', '∏', '∫', '⋃', '⋂', 'lim', '\\sum', '\\prod', '\\int'].includes(baseText) || ['∑', '∏', '∫', '⋃', '⋂'].includes(base)) return `${collect(ch[0])}_{${under}}^{${over}}`;
return `\\underset{${under}}{\\overset{${over}}{${base}}}`;
}
case 'mo': {
const t = txt();
if (MATHML_OP_MAP[t]) return MATHML_OP_MAP[t];
if (t === '(' || t === ')' || t === '[' || t === ']') return t;
if (t === '{') return '\\{'; if (t === '}') return '\\}'; if (t === '|') return '|';
return t;
}
case 'mi': {
const t = txt();
if (t.length === 1 && /[a-zA-Z]/.test(t)) return t;
if (/^(sin|cos|tan|cot|sec|csc|log|ln|exp|lim|max|min|sup|inf|det|dim|ker|im|arg|deg|gcd|lcm|mod|Pr|arcsin|arccos|arctan|sinh|cosh|tanh|coth|sech|csch|arsinh|arcosh|artanh)$/i.test(t)) return `\\${t.toLowerCase()}`;
return MATHML_OP_MAP[t] ?? t;
}
case 'mn': return txt();
case 'mtext': { const t = txt(); return t.trim() ? `\\text{${t}}` : t; }
case 'mspace': return '\\,';
case 'mphantom': return `\\phantom{${ch.map(collect).join('')}}`;
case 'mrow': case 'math': case 'semantics': case 'mstyle': case 'mpadded': return ch.map(collect).join('');
case 'mtable': {
const rows = Array.from(node.querySelectorAll(':scope > mtr'));
const content = rows.map(mtr => Array.from(mtr.querySelectorAll(':scope > mtd')).map(collect).join(' & ')).join(' \\\\ ');
return `\\begin{matrix} ${content} \\end{matrix}`;
}
case 'mfenced': {
const open = node.getAttribute('open') || '(', close = node.getAttribute('close') || ')', sep = node.getAttribute('separators') || ',';
const inner = ch.map(collect).join(` ${sep.trim()} `);
const leftMap = { '(': '(', '[': '[', '{': '\\{', '|': '|', '‖': '\\|', '⟨': '\\langle', '〈': '\\langle', '': '' };
const rightMap = { ')': ')', ']': ']', '}': '\\}', '|': '|', '‖': '\\|', '⟩': '\\rangle', '〉': '\\rangle', '': '' };
const l = leftMap[open] ?? open, r = rightMap[close] ?? close;
if (l || r) return `\\left${l || '.'}${inner}\\right${r || '.'}`;
return inner;
}
case 'menclose': {
const notation = node.getAttribute('notation') || 'box', inner = ch.map(collect).join('');
if (notation.includes('box') || notation.includes('roundedbox')) return `\\boxed{${inner}}`;
if (notation.includes('circle')) return `\\circled{${inner}}`;
if (notation.includes('updiagonalstrike') || notation.includes('downdiagonalstrike')) return `\\cancel{${inner}}`;
if (notation.includes('horizontalstrike')) return `\\hcancel{${inner}}`;
if (notation.includes('radical')) return `\\sqrt{${inner}}`;
return inner;
}
case 'annotation': case 'annotation-xml': case 'none': case 'mprescripts': return '';
case 'mmultiscripts': {
let result = ch.length > 0 ? collect(ch[0]) : '', i = 1;
while (i < ch.length && ch[i].tagName?.toLowerCase() !== 'mprescripts') {
const sub = ch[i] ? collect(ch[i]) : '', sup = ch[i + 1] ? collect(ch[i + 1]) : '';
if (sub && sub !== 'none') result += `_{${sub}}`;
if (sup && sup !== 'none') result += `^{${sup}}`;
i += 2;
}
return result;
}
default: return ch.length ? ch.map(collect).join('') : txt();
}
};
const content = collect(mathEl).trim(); if (!content) return '';
return mathEl.getAttribute('display') === 'block' ? `\n\n$$\n${content}\n$$\n\n` : `$${content}$`;
} catch (e) { console.warn('[mdltx] processMathML error:', e); return ''; }
}
function processWikipediaMath(el) {
try {
if (!el?.classList?.contains('mwe-math-element') && !el?.closest?.('.mwe-math-element')) return null;
const host = el.classList?.contains('mwe-math-element') ? el : el.closest('.mwe-math-element'); if (!host) return null;
const dl = host.closest?.('dl'), dd = host.closest?.('dd');
const inMwExtMathDisplay = !!host.closest?.('.mw-ext-math-display') || !!host.closest?.('.mw-ext-math') || !!(dl && /mw-ext-math-display|mw-ext-math/i.test(dl.className || '')) || !!(dd && /mw-ext-math-display|mw-ext-math/i.test(dd.className || ''));
const isDdOnlyDl = !!(dd && dl && !dl.querySelector?.(':scope > dt'));
const inDisplayFallback = !!host.closest?.('.mwe-math-fallback-image-display');
const wrap = (tex, isBlock) => {
tex = String(tex || '').trim(); if (!tex) return '';
if (isBlock && /^\{\s*\\displaystyle\b/i.test(tex) && /\}\s*$/.test(tex)) tex = tex.replace(/^\{\s*\\displaystyle\s*/i, '').replace(/\}\s*$/i, '').trim();
return isBlock ? `\n\n$$\n${tex}\n$$\n\n` : `$${tex}$`;
};
const mathEl = host.querySelector?.('math') || (host.tagName === 'MATH' ? host : null);
const shouldBeBlock = (mathEl?.getAttribute?.('display') === 'block') || inMwExtMathDisplay || isDdOnlyDl || inDisplayFallback;
if (mathEl) {
const alttext = mathEl.getAttribute?.('alttext'); if (alttext) return wrap(alttext, shouldBeBlock);
const tex2 = extractTex(mathEl); if (tex2) return wrap(tex2, shouldBeBlock);
const res = processMathML(mathEl);
if (res) { if (shouldBeBlock && /^\$[^$][\s\S]*\$$/.test(res) && !/^\$\$/.test(res)) { const inner = res.slice(1, -1); return `\n\n$$\n${inner}\n$$\n\n`; } return res; }
return null;
}
const img = host.querySelector?.('img.mwe-math-fallback-image-inline, img.mwe-math-fallback-image-display');
if (img) {
const alt = (img.getAttribute('alt') || '').trim(); if (!alt) return null;
const imgIsBlock = img.classList.contains('mwe-math-fallback-image-display') || shouldBeBlock || (img.closest?.('.mw-ext-math-display') !== null);
return wrap(alt, imgIsBlock);
}
return null;
} catch (e) { console.warn('[mdltx] processWikipediaMath error:', e); return null; }
}
function wikipediaImgToTex(imgEl) {
try {
if (!imgEl?.classList) return '';
const isWikiInline = imgEl.classList.contains('mwe-math-fallback-image-inline'), isWikiBlock = imgEl.classList.contains('mwe-math-fallback-image-display');
if (!isWikiInline && !isWikiBlock) return '';
const alt = (imgEl.getAttribute('alt') || '').trim(); if (!alt) return '';
let tex = alt.replace(/^\{\s*\\displaystyle\s*/i, '').replace(/\}\s*$/i, '').trim();
tex = tex.replace(/^\$(.*)\$$/, '$1').trim(); if (!tex) return '';
const block = isWikiBlock || (imgEl.closest?.('.mw-ext-math-display') !== null);
return block ? `\n\n$$\n${tex}\n$$\n\n` : `$${tex}$`;
} catch { return ''; }
}
// ─────────────────────────────────────────────────────────────
// § 語言偵測與平台偵測
// ─────────────────────────────────────────────────────────────
function normalizeLanguage(lang) {
if (!lang) return '';
lang = String(lang).toLowerCase().trim().replace(/^(language-|lang-|hljs-|prism-|shiki-|syntax-|code-)/, '').replace(/(-language|-lang|-code|-syntax|-highlight)$/, '');
lang = lang.split(/[\s,;|]+/)[0] || '';
return LANGUAGE_ALIASES[lang] || lang;
}
function inferLangFromContent(content) {
if (!S.get('enableContentBasedLangDetection') || !content || typeof content !== 'string') return '';
const text = content.trim().slice(0, 1500), firstLine = text.split('\n')[0] || '';
if (text.startsWith('#!')) {
if (/python|python3/.test(firstLine)) return 'python';
if (/\b(bash|sh|zsh|ksh)\b/.test(firstLine)) return 'bash';
if (/\bnode\b/.test(firstLine)) return 'javascript';
if (/\bruby\b/.test(firstLine)) return 'ruby';
if (/\bperl\b/.test(firstLine)) return 'perl';
if (/\bphp\b/.test(firstLine)) return 'php';
if (/\blua\b/.test(firstLine)) return 'lua';
if (/\bawk\b/.test(firstLine)) return 'awk';
const m = firstLine.match(/env\s+(\w+)/); if (m) return normalizeLanguage(m[1]);
}
if (/^<!DOCTYPE\s+html/i.test(text) || /^<html[\s>]/i.test(text)) return 'html';
if (/^<\?xml\s/i.test(text)) return 'xml';
if (/^<svg[\s>]/i.test(text)) return 'svg';
if (/^<\!--[\s\S]*?-->/.test(text) && /<\w+[\s>]/.test(text) && /<html|<head|<body|<div|<span|<p\s|<a\s|<script|<style/i.test(text)) return 'html';
const mdPatterns = [/^#{1,6}\s+.+$/m, /^\s*[-*+]\s+.+$/m, /^\s*\d+\.\s+.+$/m, /\[.+?\]\(.+?\)/, /\*\*.+?\*\*|__.+?__/, /^```[\s\S]*?```$/m, /^>\s+.+$/m, /^\s*[-*_]{3,}\s*$/m];
let mdScore = 0; for (const p of mdPatterns) if (p.test(text)) mdScore++;
if (mdScore >= 3 || (/^#{1,6}\s+.+\n/.test(text) && mdScore >= 1)) return 'markdown';
if (/^\s*[\[{]/.test(text) && /[\]}]\s*$/.test(text)) {
if (/^\s*\{[\s\S]*"[^"]+"\s*:/.test(text) || /^\s*\[[\s\S]*\{/.test(text)) { try { JSON.parse(text); return 'json'; } catch { if (/^\s*\{/.test(text) && /"[^"]+"\s*:/.test(text)) return 'json'; } }
}
if (/^---\s*\n/.test(text) || /^%YAML\s/i.test(text)) return 'yaml';
if (/^[a-z_][a-z0-9_]*:\s*.+$/im.test(text) && !/^\s*\{/.test(text) && !/^\s*\[/.test(text)) { const yamlLines = text.split('\n').filter(l => /^[a-z_][a-z0-9_]*:\s*/i.test(l.trim())); if (yamlLines.length >= 2) return 'yaml'; }
if (/^\s*\[[a-z_][a-z0-9_]*\]\s*$/im.test(text) && /^[a-z_][a-z0-9_]*\s*=\s*/im.test(text)) return 'toml';
if (/^\s*\[.+\]\s*$/m.test(text) && /^[a-z_][a-z0-9_]*\s*=\s*.+$/im.test(text) && !/^\s*\{/.test(text)) return 'ini';
if (/^(@import|@charset|@media|@keyframes|@font-face)\s/m.test(text)) return 'css';
if (/^[.#]?[a-z][a-z0-9_-]*\s*\{[\s\S]*?\}/im.test(text) && /:\s*[^;]+;/.test(text)) return 'css';
if (/^\$[a-z_][a-z0-9_-]*\s*:/im.test(text)) return 'scss';
const jsPatterns = [/^(const|let|var|function|class|import|export|async|await)\s/m, /=>\s*[\{(]/, /\.then\s*\(/, /console\.(log|error|warn)\s*\(/, /document\.(getElementById|querySelector|createElement)/, /window\./, /require\s*\(/, /module\.exports/];
let jsScore = 0; for (const p of jsPatterns) if (p.test(text)) jsScore++;
const tsPatterns = [/:\s*(string|number|boolean|any|void|never|unknown|object)\b/, /interface\s+[A-Z]/, /type\s+[A-Z]\w*\s*=/, /<[A-Z]\w*>/, /as\s+(string|number|boolean|any|const)\b/];
let tsScore = 0; for (const p of tsPatterns) if (p.test(text)) tsScore++;
if (tsScore >= 2) return 'typescript'; if (jsScore >= 2) return 'javascript';
const pyPatterns = [/^(def|class|import|from|if|elif|else|for|while|try|except|with|async|await)\s/m, /^\s*def\s+\w+\s*\([^)]*\)\s*:/m, /^\s*class\s+\w+.*:/m, /print\s*\(/, /^\s*@\w+/m, /self\./, /__init__|__name__|__main__/, /import\s+(os|sys|re|json|requests|numpy|pandas)/];
let pyScore = 0; for (const p of pyPatterns) if (p.test(text)) pyScore++; if (pyScore >= 2) return 'python';
if (/^(public|private|protected)\s+(static\s+)?(class|interface|enum)\s/m.test(text)) return 'java';
if (/^package\s+[a-z][a-z0-9_.]*;/m.test(text)) return 'java';
if (/System\.out\.print(ln)?\s*\(/.test(text)) return 'java';
if (/^#include\s*<[a-z_]+\.h>/m.test(text)) return 'c';
if (/^#include\s*<(iostream|string|vector|map|set|algorithm|memory)>/m.test(text)) return 'cpp';
if (/std::(cout|cin|endl|string|vector|map|set)\b/.test(text)) return 'cpp';
if (/^(using\s+namespace\s+std|template\s*<)/m.test(text)) return 'cpp';
if (/^using\s+(System|Microsoft|Newtonsoft)/m.test(text)) return 'csharp';
if (/^namespace\s+[A-Z]/m.test(text) && /class\s+[A-Z]/.test(text)) return 'csharp';
if (/Console\.Write(Line)?\s*\(/.test(text)) return 'csharp';
if (/^package\s+\w+\s*$/m.test(text) && /^(import|func|type|var|const)\s/m.test(text)) return 'go';
if (/fmt\.(Print|Println|Printf|Sprintf)\s*\(/.test(text)) return 'go';
if (/^(fn|pub\s+fn|impl|struct|enum|trait|mod|use|let\s+mut)\s/m.test(text)) { if (/println!\s*\(|eprintln!\s*\(|format!\s*\(/.test(text) || /fn\s+\w+\s*\([^)]*\)\s*(->|\{)/.test(text)) return 'rust'; }
if (/^(require|require_relative|gem|class|module|def|end)\s/m.test(text)) { if (/^\s*end\s*$/m.test(text) && /^\s*def\s+\w+/m.test(text)) return 'ruby'; if (/puts\s|\.each\s+do\s*\|/.test(text)) return 'ruby'; }
if (/^<\?php\s/m.test(text) || /^\s*<\?=/m.test(text)) return 'php';
if (/\$[a-z_][a-z0-9_]*\s*=/i.test(text) && /\bfunction\s+\w+\s*\(/i.test(text)) return 'php';
const shellPatterns = [/^\s*(if|then|else|elif|fi|for|do|done|while|case|esac)\s/m, /\$\{?\w+\}?/, /^\s*echo\s+/m, /^\s*(export|source|alias)\s/m, /\|\s*(grep|awk|sed|cut|sort|uniq|wc)\s/];
let shellScore = 0; for (const p of shellPatterns) if (p.test(text)) shellScore++; if (shellScore >= 2) return 'bash';
if (/^\s*\$[A-Z][a-zA-Z0-9_]*\s*=/m.test(text) && /\b(Get-|Set-|New-|Remove-|Write-Host|Write-Output)\b/.test(text)) return 'powershell';
if (/^\s*(function|param|begin|process|end)\s/im.test(text) && /\$[A-Z]/m.test(text)) return 'powershell';
if (/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|GRANT|REVOKE)\s/im.test(text)) return 'sql';
if (/\b(FROM|WHERE|JOIN|GROUP\s+BY|ORDER\s+BY|HAVING)\b/i.test(text)) return 'sql';
if (/^FROM\s+\w+/m.test(text) && /^(RUN|CMD|ENTRYPOINT|COPY|ADD|ENV|EXPOSE|WORKDIR)\s/m.test(text)) return 'dockerfile';
if (/^[a-z_][a-z0-9_-]*\s*:/m.test(text) && /^\t/.test(text)) return 'makefile';
if (/^\.(PHONY|SUFFIXES|DEFAULT)\s*:/m.test(text)) return 'makefile';
if (/^(diff\s+--git|---\s+a\/|@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@)/m.test(text)) return 'diff';
if (/^[-+]{3}\s+/.test(text) && /^[-+](?![-+])/m.test(text)) return 'diff';
if (/^\\documentclass|^\\usepackage|^\\begin\{document\}/m.test(text)) return 'latex';
if (/\\(section|subsection|chapter|title|author|maketitle|textbf|textit)\{/.test(text)) return 'latex';
if (/^\s*(query|mutation|subscription|fragment|type|input|enum|interface|union|scalar)\s+\w+/m.test(text)) return 'graphql';
return '';
}
function isAIChatPlatform() { try { const h = location.hostname.toLowerCase(); if (AI_CHAT_PLATFORM_HOSTS.has(h)) return true; for (const host of AI_CHAT_PLATFORM_HOSTS) if (h.endsWith('.' + host) || h === host) return true; return false; } catch { return false; } }
function isLMArenaHost() {
try {
const h = location.hostname.toLowerCase();
return /lmarena\.ai$/i.test(h) || /arena\.ai$/i.test(h);
} catch { return false; }
}
function isArenaHost() {
try {
const h = location.hostname.toLowerCase();
return h === 'arena.ai' || h.endsWith('.arena.ai') ||
h === 'lmarena.ai' || h.endsWith('.lmarena.ai');
} catch { return false; }
}
function isClaudeHost() { try { return /claude\.ai$/i.test(location.hostname); } catch { return false; } }
function isGrokHost() { try { return /grok\.com$/i.test(location.hostname); } catch { return false; } }
function isChatGPTHost() { try { const h = location.hostname.toLowerCase(); return h === 'chat.openai.com' || h === 'chatgpt.com'; } catch { return false; } }
function detectLangFromAIChatPlatform(codeEl) {
try {
const preParent = codeEl.closest('pre'), container = preParent?.parentElement || codeEl.parentElement; if (!container) return '';
if (isClaudeHost()) {
const claudeContainer = codeEl.closest('[data-code-block-id]') || codeEl.closest('[class*="code-block"]');
if (claudeContainer) { const lang = claudeContainer.getAttribute('data-language') || claudeContainer.querySelector('[data-language]')?.getAttribute('data-language'); if (lang) return normalizeLanguage(lang); }
const toolbar = container.querySelector('[class*="sticky"]') || container.previousElementSibling;
if (toolbar) { for (const span of toolbar.querySelectorAll('span, div')) { const text = (span.textContent || '').trim().toLowerCase(); if (text && text.length < 20 && KNOWN_LANGUAGES.has(text)) return text; } }
}
if (isChatGPTHost()) {
const header = container.querySelector('[class*="code-block-header"]') || container.querySelector('[class*="flex"][class*="justify-between"]');
if (header) { const langSpan = header.querySelector('span:first-child') || header.querySelector('[class*="text-xs"]'); if (langSpan) { const text = (langSpan.textContent || '').trim().toLowerCase(); if (text && KNOWN_LANGUAGES.has(normalizeLanguage(text))) return normalizeLanguage(text); } }
if (preParent) { const langMatch = (preParent.className || '').match(/language-([a-z0-9_+-]+)/i); if (langMatch) return normalizeLanguage(langMatch[1]); }
}
if (isGrokHost()) {
const grokContainer = codeEl.closest('[class*="message-content"]') || codeEl.closest('[class*="prose"]');
if (grokContainer) { const codeBlock = codeEl.closest('[class*="code"]'), header = codeBlock?.querySelector('[class*="header"]') || codeBlock?.previousElementSibling;
if (header) { const text = (header.textContent || '').trim().toLowerCase(), normalized = normalizeLanguage(text.split(/[\s.]+/)[0]); if (normalized && KNOWN_LANGUAGES.has(normalized)) return normalized; } }
}
const possibleHeaders = [container.querySelector('[class*="header"]'), container.querySelector('[class*="toolbar"]'), container.querySelector('[class*="title"]'), container.previousElementSibling, preParent?.previousElementSibling].filter(Boolean);
for (const header of possibleHeaders) { if (!header) continue; const firstWord = (header.textContent || '').trim().split(/[\s\n]+/)[0]?.toLowerCase(); if (firstWord && firstWord.length < 20) { const normalized = normalizeLanguage(firstWord); if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) return normalizeLanguage(normalized); } }
} catch (e) { console.warn('[mdltx] detectLangFromAIChatPlatform error:', e); }
return '';
}
function detectLangFromLMArena(codeEl) {
try {
const preParent = codeEl.closest('pre'), container = preParent?.closest('div[class*="relative"]') || codeEl.closest('div[class*="relative"]') || preParent?.parentElement;
if (container) {
const header = container.querySelector('[class*="flex"][class*="justify-between"]') || container.querySelector('[class*="toolbar"]') || container.querySelector('[class*="header"]') || container.firstElementChild;
if (header && header !== preParent) {
for (const el of header.querySelectorAll('span, div, button')) { const text = (el.textContent || '').trim().toLowerCase(); if (text && text.length < 25 && !/^(copy|copied|share|run|edit|expand|collapse|\d+)$/i.test(text)) { const normalized = normalizeLanguage(text.split(/[\s.]+/)[0]); if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) return normalizeLanguage(normalized); } }
}
const langFromData = container.getAttribute('data-language') || container.getAttribute('data-lang') || container.getAttribute('data-code-lang'); if (langFromData) return normalizeLanguage(langFromData);
}
if (preParent) { const shikiLang = preParent.getAttribute('data-lang') || preParent.getAttribute('data-language'); if (shikiLang) return normalizeLanguage(shikiLang);
const shikiMatch = (preParent.className || '').match(/shiki[_-]?(?:lang[_-])?([a-z0-9_+-]+)/i); if (shikiMatch && !['light', 'dark', 'themes', 'nord', 'dracula'].includes(shikiMatch[1].toLowerCase())) return normalizeLanguage(shikiMatch[1]); }
} catch (e) { console.warn('[mdltx] detectLangFromLMArena error:', e); }
return '';
}
/**
* 針對 Arena.ai 的增強語言偵測
* Arena 使用 Shiki 高亮,語言名稱常在 pre 前的文字節點或 header 中
*/
function detectLangFromArena(codeEl) {
try {
const preParent = codeEl.closest('pre');
// 方法 1:從 Shiki class 提取
if (preParent) {
const shikiMatch = (preParent.className || '').match(/shiki[_-]?(?:lang[_-])?([a-z0-9_+-]+)/i);
if (shikiMatch) {
const candidate = shikiMatch[1].toLowerCase();
if (!['light', 'dark', 'themes', 'github', 'nord', 'dracula', 'code', 'block'].includes(candidate)) {
return normalizeLanguage(candidate);
}
}
const dataLang = preParent.getAttribute('data-lang') || preParent.getAttribute('data-language');
if (dataLang) return normalizeLanguage(dataLang);
}
// 方法 2:從 code-block 容器的 header 查找
const container = codeEl.closest('[class*="code-block_container"]') ||
codeEl.closest('[class*="code-block"]') ||
codeEl.closest('[class*="not-prose"]') ||
preParent?.parentElement;
if (container) {
const header = container.querySelector('[class*="flex"][class*="justify-between"]') ||
container.querySelector('[class*="toolbar"]') ||
container.querySelector('[class*="header"]');
if (header && header !== preParent && header !== codeEl) {
for (const span of header.querySelectorAll('span, div, button')) {
const text = (span.textContent || '').trim().toLowerCase();
if (text && text.length < 30 && !/^(copy|copied|share|run|edit|lines?|\d+)$/i.test(text)) {
const normalized = normalizeLanguage(text.split(/[\s.]+/)[0]);
if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) {
return normalizeLanguage(normalized);
}
}
}
}
}
// 方法 3:從 pre 前的文字節點提取(Arena 特有結構)
const proseParent = codeEl.closest('.prose, [class*="prose"]');
if (proseParent && preParent) {
let prev = preParent.previousSibling;
while (prev && prev.nodeType === 3 && !(prev.textContent || '').trim()) {
prev = prev.previousSibling;
}
if (prev && prev.nodeType === 3) {
const text = (prev.textContent || '').trim().toLowerCase();
const normalized = normalizeLanguage(text);
if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) {
return normalizeLanguage(normalized);
}
}
}
} catch (e) {
console.warn('[mdltx] detectLangFromArena error:', e);
}
return '';
}
function detectLang(codeEl) {
if (!codeEl) return '';
try {
const annotated = codeEl.getAttribute?.('data-mdltx-lang') || codeEl.closest?.('[data-mdltx-lang]')?.getAttribute('data-mdltx-lang'); if (annotated) return normalizeLanguage(annotated);
const dataAttrs = ['data-language', 'data-lang', 'data-syntax', 'data-code-language', 'data-code-lang', 'data-highlight', 'data-prismjs', 'data-shiki-lang'];
for (const attr of dataAttrs) { const val = codeEl.getAttribute?.(attr); if (val) { const normalized = normalizeLanguage(val); if (normalized && normalized !== 'text') return normalized; } }
const classList = (codeEl.className || '').toLowerCase();
const langMatch = classList.match(/(?:^|\s)(language|lang|hljs|prism|shiki|syntax|brush|highlight)-([a-z0-9_+-]+)/i);
if (langMatch && langMatch[2]) { const normalized = normalizeLanguage(langMatch[2]); if (normalized && !['line', 'number', 'copy', 'wrapper', 'block', 'inline', 'light', 'dark'].includes(normalized)) return normalized; }
for (const lang of KNOWN_LANGUAGES) if (new RegExp(`(?:^|\\s|-)${lang}(?:$|\\s|-)`, 'i').test(classList)) return lang;
const preParent = codeEl.closest('pre');
if (preParent && preParent !== codeEl) {
for (const attr of dataAttrs) { const val = preParent.getAttribute?.(attr); if (val) { const normalized = normalizeLanguage(val); if (normalized && normalized !== 'text') return normalized; } }
const preLangMatch = (preParent.className || '').toLowerCase().match(/(?:^|\s)(language|lang|hljs|prism|shiki)-([a-z0-9_+-]+)/i);
if (preLangMatch && preLangMatch[2]) { const normalized = normalizeLanguage(preLangMatch[2]); if (normalized && !['line', 'number', 'copy', 'wrapper', 'block'].includes(normalized)) return normalized; }
}
if (S.get('aiChatPlatformDetection') && isAIChatPlatform()) { const aiLang = detectLangFromAIChatPlatform(codeEl); if (aiLang) return aiLang; }
// Arena.ai 專用偵測(支援新舊域名)
if (S.get('lmArenaEnhancedDetection') && (isLMArenaHost() || isArenaHost())) {
const arenaLang = detectLangFromArena(codeEl);
if (arenaLang) return arenaLang;
const lmLang = detectLangFromLMArena(codeEl);
if (lmLang) return lmLang;
}
const shikiEl = codeEl.closest('[class*="shiki"]') || preParent?.closest('[class*="shiki"]');
if (shikiEl) { const shikiLang = shikiEl.getAttribute('data-lang') || shikiEl.getAttribute('data-language'); if (shikiLang) return normalizeLanguage(shikiLang);
const shikiMatch = shikiEl.className.match(/shiki[_-](?:lang[_-])?([a-z0-9_+-]+)/i); if (shikiMatch && !['light', 'dark', 'themes'].includes(shikiMatch[1])) return normalizeLanguage(shikiMatch[1]); }
const hljsEl = codeEl.closest('[class*="hljs"]') || codeEl.querySelector('[class*="hljs"]');
if (hljsEl) { const hljsMatch = hljsEl.className.match(/hljs[_-]?([a-z0-9_+-]+)/i); if (hljsMatch && !['line', 'number', 'copy', 'wrapper', 'ln', 'code'].includes(hljsMatch[1])) return normalizeLanguage(hljsMatch[1]); }
const prismMatch = classList.match(/(?:^|\s)(?:prism-)?([a-z0-9_+-]+)(?:\s|$)/i); if (prismMatch) { const candidate = normalizeLanguage(prismMatch[1]); if (KNOWN_LANGUAGES.has(candidate)) return candidate; }
const container = codeEl.closest('[class*="code"]') || preParent?.parentElement;
if (container) {
const langLabelSelectors = ['[class*="language-label"]', '[class*="code-lang"]', '[class*="lang-label"]', '[class*="code-header"] span', '[class*="toolbar"] span', '[class*="filename"]', '[class*="code-title"]', 'span[class*="text-xs"]', 'span[class*="text-sm"]', '.code-block-extension-header', '.code-block-header'];
for (const sel of langLabelSelectors) { try { const label = container.querySelector(sel); if (label) { const labelText = (label.textContent || '').trim().toLowerCase(); if (labelText && labelText.length < 25 && !/^(copy|copied|code|share|\d+\s*lines?|run|preview|edit)$/i.test(labelText)) { const normalized = normalizeLanguage(labelText.split(/[\s.]+/)[0]); if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) return normalizeLanguage(normalized); } } } catch {} }
for (const attr of dataAttrs) { const val = container.getAttribute?.(attr); if (val) { const normalized = normalizeLanguage(val); if (normalized && normalized !== 'text') return normalized; } }
}
if (S.get('enableContentBasedLangDetection')) { const content = codeEl.textContent || ''; if (content.trim()) { const inferred = inferLangFromContent(content); if (inferred) return inferred; } }
} catch (e) { console.warn('[mdltx] detectLang error:', e); }
return '';
}
function annotateCodeBlockLanguages(scope) {
const tagged = [];
try {
// 擴展選擇器以包含 Arena 的程式碼區塊結構
const codeBlocks = (scope || document.body).querySelectorAll(
'pre code, pre[class*="shiki"], pre[class*="hljs"], [class*="code-block_container"] pre, [class*="code-block"] pre'
);
for (const codeEl of codeBlocks) {
if (codeEl.hasAttribute('data-mdltx-lang') ||
codeEl.closest?.('[data-mdltx-ui="1"]') ||
codeEl.closest?.('[data-mdltx-hidden="1"]')) continue;
let lang = detectLang(codeEl);
// Arena 特殊處理:語言名稱可能在 pre 前面作為文字節點
if (!lang && (isArenaHost() || isLMArenaHost())) {
const preEl = codeEl.tagName === 'PRE' ? codeEl : codeEl.closest('pre');
if (preEl) {
let prev = preEl.previousSibling;
while (prev && prev.nodeType === 3 && !(prev.textContent || '').trim()) {
prev = prev.previousSibling;
}
if (prev && prev.nodeType === 3) {
const text = (prev.textContent || '').trim().toLowerCase();
const normalized = normalizeLanguage(text);
if (KNOWN_LANGUAGES.has(normalized) || LANGUAGE_ALIASES[normalized]) {
lang = normalizeLanguage(normalized);
}
}
}
}
codeEl.setAttribute('data-mdltx-lang', lang || '');
tagged.push(codeEl);
}
} catch (e) { console.warn('[mdltx] annotateCodeBlockLanguages error:', e); }
return tagged;
}
// ─────────────────────────────────────────────────────────────
// § 輔助函數與文字處理
// ─────────────────────────────────────────────────────────────
function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function isInlineishNode(n) { return n && ((n.nodeType === 3 && (n.nodeValue || '').trim()) || (n.nodeType === 1 && (INLINEISH_TAGS.has(n.tagName) || n.matches?.('.katex,.katex-display,mjx-container,.MathJax,span.MathJax,math,.mwe-math-element,img.mwe-math-fallback-image-inline,img.mwe-math-fallback-image-display')))); }
function wsTextNodeToSpace(textNode) { const p = textNode.previousSibling, n = textNode.nextSibling; return (p && n && isInlineishNode(p) && isInlineishNode(n)) ? ' ' : ''; }
function isBlockBoundary(node) { return node?.nodeType === 1 && (BLOCK_TAGS.has(node.tagName) || node.tagName === 'BR' || node.hasAttribute?.('data-mdltx-block')); }
function containsBlockishContent(el) { return el?.nodeType === 1 && (el.querySelector('br') || [...BLOCK_TAGS].some(tag => el.querySelector(tag.toLowerCase())) || el.querySelector('[data-mdltx-block="1"]')); }
function hasUnsafeMarkdownBlocks(el) { return el?.nodeType === 1 && !!el.querySelector('ul,ol,li,table,pre,blockquote'); }
function processUnknownEmptyTag(el, ctx) {
const strategy = S.get('unknownEmptyTagStrategy'); if (strategy === 'drop') return '';
if (el.closest?.('svg') || el.closest?.('math') || el.tagName?.includes('-')) return null;
if (!KNOWN_HTML_TAGS.has(el.tagName) && el.childNodes.length === 0) { const tagName = el.tagName.toLowerCase(); return ctx?.escapeText ? `<${tagName}>` : `<${tagName}>`; }
return null;
}
function processRuby(rubyEl) { let result = ''; for (const child of rubyEl.childNodes) { if (child.nodeType === 3) result += child.nodeValue || ''; else if (child.nodeType === 1 && !/^(RT|RP)$/.test(child.tagName)) result += child.tagName === 'RUBY' ? processRuby(child) : (child.textContent || ''); } return result; }
function processSvg(svgEl) { const texts = []; try { const title = svgEl.querySelector('title'); if (title?.textContent?.trim()) texts.push(title.textContent.trim()); const desc = svgEl.querySelector('desc'); if (desc?.textContent?.trim()) texts.push(desc.textContent.trim()); svgEl.querySelectorAll('text').forEach(t => { if (t.textContent?.trim()) texts.push(t.textContent.trim()); }); } catch {} return texts.join(' '); }
function smartConcat(out, part) {
if (!out) return part; if (!part) return out;
for (const [len, mk] of [[3, '***'], [2, '**'], [2, '~~'], [1, '*']]) if (out.slice(-len) === mk && part.slice(0, len) === mk) return out.slice(0, -len) + part.slice(len);
if (S.get('mergeAdjacentCodeSpans')) { const outM = out.match(/(`+)([^`]+)\1$/), partM = part.match(/^(`+)([^`]+)\1/); if (outM && partM) return out.slice(0, -outM[0].length) + wrapInlineCode(outM[2] + ' ' + partM[2]) + part.slice(partM[0].length); }
return out + part;
}
function trimNewlinesOnly(s) { return String(s || '').replace(/^\n+/, '').replace(/\n+$/, ''); }
function normalizeCtx(ctx) { return { depth: ctx?.depth ?? 0, escapeText: ctx?.escapeText ?? S.get('escapeMarkdownChars'), inTable: ctx?.inTable ?? false, baseUri: ctx?.baseUri ?? document.baseURI }; }
function getListMarker() { return S.get('listMarker') || '-'; }
function getEmphasisMarker() { return S.get('emphasisMarker') || '*'; }
function getStrongMarker() { return S.get('strongMarker') || '**'; }
function getHorizontalRule() { return S.get('horizontalRule') || '---'; }
function waitForDomIdle(timeout) {
return new Promise(resolve => {
let timer = null;
const observer = new MutationObserver(() => { if (timer) clearTimeout(timer); timer = setTimeout(() => { observer.disconnect(); resolve(); }, timeout); });
observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
timer = setTimeout(() => { observer.disconnect(); resolve(); }, timeout);
});
}
function escapeMarkdownText(s, ctx) { s = String(s ?? ''); if (ctx?.inTable) s = s.replace(/\|/g, '|'); if (ctx?.escapeText) s = s.replace(/([\\*_`\[\]~])/g, '\\$1').replace(/</g, '<').replace(/>/g, '>'); return s; }
function escapeBracketText(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]'); }
function escapeLinkLabel(s, ctx) { s = String(s ?? '').replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]'); if (ctx?.escapeText) s = s.replace(/\*/g, '\\*').replace(/_/g, '\\_').replace(/~/g, '\\~'); return s; }
function escapeLinkDest(url, inTable = false) { url = String(url || '').trim(); if (!url) return ''; if (inTable) url = url.replace(/\|/g, '%7C'); if (/[()\s"<>]/.test(url)) return `<${encodeURI(url).replace(/</g, '%3C').replace(/>/g, '%3E').replace(/\|/g, '%7C')}>`; return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)'); }
function mdLink(text, href, inTable = false) { const lt = escapeBracketText(text || ''), lh = escapeLinkDest(href || '', inTable); return lh ? `[${lt}](${lh})` : lt; }
function wrapInlineCode(text) {
text = String(text ?? ''); if (!text) return '``';
let maxLen = 0; for (const t of (text.match(/`+/g) || [])) maxLen = Math.max(maxLen, t.length);
const wrapper = '`'.repeat(Math.max(1, maxLen + 1)), needsPad = text[0] === '`' || text.slice(-1) === '`' || text[0] === ' ' || text.slice(-1) === ' ';
return needsPad ? `${wrapper} ${text} ${wrapper}` : `${wrapper}${text}${wrapper}`;
}
function chooseFence(content) { const maxBt = maxRunOfChar(content, '`'), maxTl = maxRunOfChar(content, '~'), ch = maxBt <= maxTl ? '`' : '~'; return ch.repeat(Math.max(3, (ch === '`' ? maxBt : maxTl) + 1)); }
function maxRunOfChar(s, ch) { let max = 0, cur = 0; for (let i = 0; i < s.length; i++) { if (s[i] === ch) { if (++cur > max) max = cur; } else cur = 0; } return max; }
function absUrl(url, baseUri) { if (!S.get('absoluteUrls')) return url || ''; try { return new URL(url, baseUri || document.baseURI || location.href).href; } catch { return url || ''; } }
function hrefForA(aEl, baseUri) {
try { const raw = (aEl.getAttribute?.('href') || '').trim(); if (!raw || raw.startsWith('#') || /^javascript:/i.test(raw)) return raw.startsWith('#') ? raw : ''; return S.get('absoluteUrls') ? absUrl(aEl.href || raw, baseUri) : raw; } catch { return ''; }
}
function parseSrcset(srcset) {
try { srcset = String(srcset || '').trim(); if (!srcset) return ''; let bestUrl = '', bestScore = -1;
for (const p of srcset.split(',').map(s => s.trim()).filter(Boolean)) { const m = p.match(/^(\S+)(?:\s+(\d+(?:\.\d+)?)(w|x))?$/i); if (!m) continue; const url = m[1], value = m[2] ? parseFloat(m[2]) : 1, unit = (m[3] || 'x').toLowerCase(), score = unit === 'w' ? value : value * 10000; if (score > bestScore) { bestScore = score; bestUrl = url; } }
return bestUrl;
} catch { return ''; }
}
function pickImgSrc(node) {
try { if (node.currentSrc) return node.currentSrc;
for (const a of ['src', 'data-src', 'data-original', 'data-orig', 'data-lazy-src', 'data-url', 'data-image', 'data-src-url', 'data-zoom-src', 'data-hires']) { const v = node.getAttribute?.(a); if (v) return v; }
return parseSrcset(node.getAttribute?.('srcset') || node.getAttribute?.('data-srcset') || '');
} catch { return ''; }
}
// ─────────────────────────────────────────────────────────────
// § 格式處理
// ─────────────────────────────────────────────────────────────
function wrapWithFormat(content, formatTag) {
content = String(content || '').trim(); if (!content) return '';
switch (formatTag) { case 'STRONG': case 'B': return `${getStrongMarker()}${content}${getStrongMarker()}`; case 'EM': case 'I': return `${getEmphasisMarker()}${content}${getEmphasisMarker()}`; case 'DEL': case 'S': return `~~${content}~~`; default: return content; }
}
function splitInlineFormatAcrossBlocks(node, formatTag, ctx) {
ctx = normalizeCtx(ctx); const sep = ctx.inTable ? '<br>' : '\n\n'; const parts = []; let buf = '';
const flushBuf = () => { const t = String(buf || '').trim(); if (!t) { buf = ''; return; }
const chunks = ctx.inTable ? t.split(/<br\s*\/?>/i).map(s => s.trim()).filter(Boolean) : t.split(/\n{2,}/).map(s => s.trim()).filter(Boolean);
if (chunks.length <= 1) parts.push(wrapWithFormat(t, formatTag));
else { for (let i = 0; i < chunks.length; i++) { parts.push(wrapWithFormat(chunks[i], formatTag)); if (i < chunks.length - 1) parts.push(sep); } }
buf = '';
};
const pushSep = () => { if (!parts.length || parts[parts.length - 1] === sep) return; parts.push(sep); };
const pushBlock = (blockNode) => { const inner = mdInline(blockNode, ctx).trim(); if (!inner) return; pushSep(); parts.push(wrapWithFormat(inner, formatTag)); parts.push(ctx.inTable ? '<br>' : '\n\n'); };
for (const child of Array.from(node.childNodes)) { if (child.nodeType === 1 && isBlockBoundary(child)) { flushBuf(); if (child.tagName === 'BR') parts.push('<br>'); else pushBlock(child); } else buf += mdInline(child, ctx); }
flushBuf();
let out = parts.join('');
if (ctx.inTable) out = out.replace(/(?:<br>\s*){3,}/g, '<br><br>').replace(/^(?:<br>\s*)+/, '').replace(/(?:<br>\s*)+$/, '');
else out = out.replace(/\n{3,}/g, '\n\n');
return out;
}
function processInlineFormat(node, formatTag, ctx) {
const hasBlock = containsBlockishContent(node);
if (!hasBlock) { const inner = processChildrenInline(node, ctx).trim(); return inner ? wrapWithFormat(inner, formatTag) : ''; }
if (hasUnsafeMarkdownBlocks(node)) return processChildren(node, ctx);
const strategy = S.get('strongEmBlockStrategy');
switch (strategy) {
case 'split': return splitInlineFormatAcrossBlocks(node, formatTag, ctx);
case 'strip': return processChildren(node, ctx);
case 'html': default: { const tagName = formatTag.toLowerCase(), htmlTag = tagName === 'b' ? 'strong' : tagName === 'i' ? 'em' : tagName === 's' ? 'del' : tagName; return `<${htmlTag}>${trimNewlinesOnly(processChildren(node, ctx)).trim()}</${htmlTag}>`; }
}
}
function processChildren(node, ctx, override = {}) { const uc = { ...ctx, ...override }; let r = ''; for (const c of Array.from(node.childNodes)) r = smartConcat(r, md(c, uc)); return r; }
function processChildrenInline(node, ctx, override = {}) { const uc = { ...ctx, ...override }; let r = ''; for (const c of Array.from(node.childNodes)) r = smartConcat(r, mdInline(c, uc)); return r; }
// ─────────────────────────────────────────────────────────────
// § 表格處理
// ─────────────────────────────────────────────────────────────
function tableHasComplexStructure(tbl) {
try { const mainCells = tbl.querySelectorAll(':scope > thead > tr > td, :scope > thead > tr > th, :scope > tbody > tr > td, :scope > tbody > tr > th, :scope > tr > td, :scope > tr > th');
for (const cell of mainCells) { if (cell.closest('tfoot')) continue; const rs = parseInt(cell.getAttribute('rowspan') || '1', 10), cs = parseInt(cell.getAttribute('colspan') || '1', 10); if (rs > 1 || cs > 1 || cell.querySelector(':scope > table')) return true; }
} catch {} return false;
}
function flattenListText(listMd, marker) { marker = marker || getListMarker(); const re = new RegExp(`^\\s*${escapeRegExp(marker)}\\s+`, 'gm'); return String(listMd || '').split('\n').map(l => l.replace(re, '').trim()).filter(Boolean).join('; '); }
function tableToList(tbl, ctx) {
ctx = normalizeCtx(ctx); const marker = getListMarker(), items = [];
try { const rows = Array.from(tbl.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr, :scope > tr'));
for (const tr of rows) { const cells = Array.from(tr.querySelectorAll(':scope > th, :scope > td')), cellTexts = [];
for (const cell of cells) { const parts = [], nestedTables = Array.from(cell.querySelectorAll(':scope > table'));
if (nestedTables.length) for (const nt of nestedTables) { const nestedList = tableToList(nt, ctx), flattened = flattenListText(nestedList, marker); if (flattened) parts.push(`[${flattened}]`); }
const cellClone = cell.cloneNode(true); try { cellClone.querySelectorAll(':scope > table').forEach(n => n.remove()); } catch {}
const text = cellToMd(cellClone, ctx).trim(); if (text) parts.push(text);
const combined = parts.filter(Boolean).join(' ').trim(); if (combined) cellTexts.push(combined);
}
if (cellTexts.length) { const line = `${marker} ${cellTexts.join(' | ')}`.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim(); items.push(line); }
}
} catch (e) { console.warn('[mdltx] tableToList error:', e); }
return items.join('\n');
}
function cellToMd(cell, ctx) {
ctx = normalizeCtx({ ...ctx, inTable: true }); const placeholders = {}; let pid = 0; const nonce = generateNonce();
const protect = c => { const k = makePlaceholder('CELL', nonce, pid++); placeholders[k] = c; return k; };
try { const hasBlock = !!cell.querySelector('ul,ol,pre,blockquote,p,div,br'); let result;
if (hasBlock) { const parts = [];
for (const ch of Array.from(cell.childNodes)) { if (ch.nodeType === 3) { const t = ch.nodeValue?.trim(); if (t) parts.push(escapeMarkdownText(t, ctx)); }
else if (ch.nodeType === 1) { const T = ch.tagName; if (T === 'TABLE') continue;
if (T === 'UL' || T === 'OL') parts.push(Array.from(ch.querySelectorAll('li')).map(li => mdInline(li, ctx).trim()).filter(Boolean).join('; '));
else if (T === 'PRE') { const code = ch.querySelector('code') || ch; parts.push(protect(wrapInlineCode((code.textContent || '').replace(/\n/g, ' ').trim()))); }
else if (T === 'CODE') parts.push(protect(wrapInlineCode(ch.textContent || '')));
else parts.push(mdInline(ch, ctx).trim());
}
}
result = parts.join(' ');
} else result = mdInline(cell, ctx);
result = String(result || '').replace(/(?:<br>\s*){3,}/g, '<br><br>').replace(/<br\s*\/?>/gi, ' ').replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim();
for (const [k, v] of Object.entries(placeholders)) result = result.split(k).join(v);
return result.trim();
} catch (e) { console.warn('[mdltx] cellToMd error:', e); return ''; }
}
function serializeTableAsHtml(tbl, ctx) {
try { const clone = tbl.cloneNode(true), nestedMap = new Map(); let nid = 0; const nonce = generateNonce();
clone.querySelectorAll('table table').forEach(nt => { const key = makePlaceholder('NTBL', nonce, nid++); nestedMap.set(key, serializeTableAsHtml(nt, ctx) || ''); const sp = document.createElement('span'); sp.textContent = key; nt.replaceWith(sp); });
clone.querySelectorAll('td, th').forEach(cell => { const mdContent = cellToMd(cell, ctx); cell.textContent = ''; cell.innerHTML = mdContent; });
const allowedAttrs = new Set(['rowspan', 'colspan', 'scope']);
clone.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { if (!allowedAttrs.has(attr.name)) el.removeAttribute(attr.name); }); });
let html = clone.outerHTML; for (const [k, v] of nestedMap.entries()) if (v) html = html.split(k).join(v);
return html;
} catch (e) { console.warn('[mdltx] serializeTableAsHtml error:', e); return ''; }
}
function tableToMd(tbl, ctx) {
ctx = normalizeCtx(ctx);
try { if (tableHasComplexStructure(tbl)) { const strategy = S.get('complexTableStrategy'); if (strategy === 'html') return `\n\n${serializeTableAsHtml(tbl, ctx)}\n\n`;
const caption = tbl.querySelector('caption'), captionText = caption ? mdInline(caption, ctx).trim() : '', listContent = tableToList(tbl, ctx);
return captionText ? `${getEmphasisMarker()}${captionText}${getEmphasisMarker()}\n\n${listContent}` : listContent; }
const rows = [], caption = tbl.querySelector('caption'), captionText = caption ? mdInline(caption, ctx).trim() : ''; let hasHr = false;
const mainRows = Array.from(tbl.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tr')).filter(tr => !tr.closest('tfoot'));
const tfootRows = Array.from(tbl.querySelectorAll(':scope > tfoot > tr'));
mainRows.forEach((tr, i) => { const cells = []; tr.querySelectorAll(':scope > th, :scope > td').forEach(td => { const colspan = parseInt(td.getAttribute('colspan') || '1', 10); cells.push(cellToMd(td, ctx)); for (let c = 1; c < colspan; c++) cells.push(''); });
if (!cells.length) return; rows.push(`| ${cells.join(' | ')} |`);
if (!hasHr && (tr.querySelector('th') || i === 0)) { rows.push(`| ${cells.map(() => '---').join(' | ')} |`); hasHr = true; }
});
let result = rows.join('\n');
if (tfootRows.length > 0) { const tfootTexts = []; tfootRows.forEach(tr => { const cells = []; tr.querySelectorAll(':scope > th, :scope > td').forEach(td => { const text = cellToMd(td, ctx).trim(); if (text) cells.push(text); }); if (cells.length) tfootTexts.push(cells.join(' | ')); });
if (tfootTexts.length) result += `\n\n${getEmphasisMarker()}${tfootTexts.join('; ')}${getEmphasisMarker()}`; }
return captionText ? `${getEmphasisMarker()}${captionText}${getEmphasisMarker()}\n\n${result}` : result;
} catch (e) { console.warn('[mdltx] tableToMd error:', e); return ''; }
}
function dlToMd(dl, ctx) {
ctx = normalizeCtx(ctx);
try { const isWikiMathDl = dl.classList?.contains('mw-ext-math-display'), hasDT = !!dl.querySelector?.(':scope > dt'), ddList = Array.from(dl.querySelectorAll?.(':scope > dd') || []), isDdOnlyDl = ddList.length > 0 && !hasDT;
if (isWikiMathDl || isDdOnlyDl) { const blocks = []; for (const ch of Array.from(dl.children)) if (ch.tagName === 'DT' || ch.tagName === 'DD') { const m = trimNewlinesOnly(md(ch, ctx)).trim(); if (m) blocks.push(m); } const out = blocks.join('\n\n').trim(); return out ? `\n\n${out}\n\n` : ''; }
const items = []; let curTerm = null, defs = []; const marker = getListMarker(), strong = getStrongMarker();
const flush = () => { if (!curTerm) { const defTextOnly = defs.map(d => d.trim()).filter(Boolean).join('<br>'); if (defTextOnly) items.push(`${marker} ${defTextOnly}`); curTerm = null; defs = []; return; }
const term = curTerm.trim(), defText = defs.map(d => d.trim()).filter(Boolean).join('<br>');
items.push(term && defText ? `${marker} ${strong}${term}${strong}:${defText}` : term ? `${marker} ${strong}${term}${strong}` : '');
curTerm = null; defs = [];
};
for (const ch of Array.from(dl.children)) { if (ch.tagName === 'DT') { flush(); curTerm = mdInline(ch, ctx); } else if (ch.tagName === 'DD') defs.push(mdInline(ch, ctx)); }
flush(); return items.length ? `\n\n${items.join('\n')}\n\n` : '';
} catch (e) { console.warn('[mdltx] dlToMd error:', e); return ''; }
}
function figureToMd(fig, ctx) {
ctx = normalizeCtx(ctx);
try { const imgs = Array.from(fig.querySelectorAll('img')).map(img => md(img, ctx).trim()).filter(Boolean), capEl = fig.querySelector('figcaption'), cap = capEl ? mdInline(capEl, ctx).trim() : '';
let out = imgs.length ? imgs.join('\n\n') : ''; if (cap) out += (out ? '\n\n' : '') + `${getEmphasisMarker()}${cap}${getEmphasisMarker()}`;
return out ? `\n\n${out}\n\n` : '';
} catch (e) { console.warn('[mdltx] figureToMd error:', e); return ''; }
}
// ─────────────────────────────────────────────────────────────
// § Markdown 轉換(行內)
// ─────────────────────────────────────────────────────────────
function mdInline(node, ctx) {
ctx = normalizeCtx(ctx); if (!node || isHiddenInClone(node)) return '';
try {
if (node.nodeType === 3) { const raw = node.nodeValue || '', ptag = node.parentElement?.tagName || ''; if (ptag === 'PRE' || ptag === 'CODE') return raw; if (/^\s+$/.test(raw)) return wsTextNodeToSpace(node) || (INLINE_PARENT_TAGS.has(ptag) ? ' ' : ''); return escapeMarkdownText(String(raw).replace(/\s+/g, ' '), ctx); }
if (node.nodeType !== 1) return '';
const T = node.tagName;
if (/^(SCRIPT|STYLE|NOSCRIPT|MJX-ASSISTIVE-MML|TEMPLATE)$/.test(T) || isOurUI(node)) return '';
if (S.get('ignoreNav') && isNavLike(node)) return '';
if (node.classList?.contains('mwe-math-element')) { const wiki = processWikipediaMath(node); if (wiki !== null) return wiki; }
const unknownResult = processUnknownEmptyTag(node, ctx); if (unknownResult !== null) return unknownResult;
if (T === 'SLOT') { let r = ''; for (const n of (node.assignedNodes?.({ flatten: true }) || [])) r = smartConcat(r, mdInline(n, ctx)); return r; }
if (T === 'SVG') return processSvg(node);
if (T === 'RUBY') return processRuby(node);
if (T === 'BR') return '<br>';
if (T === 'INPUT') { const type = (node.getAttribute('type') || '').toLowerCase(); if (type === 'checkbox') return (node.checked || node.defaultChecked || node.getAttribute('checked') !== null) ? '[x] ' : '[ ] '; return ''; }
if (/^(STRONG|B|EM|I|DEL|S)$/.test(T)) return processInlineFormat(node, T, ctx);
if (T === 'Q') {
const inner = processChildrenInline(node, ctx).trim();
if (!inner) return '';
const lang = detectLanguage();
const [open, close] = lang.startsWith('zh') ? ['「', '」'] : ['"', '"'];
return `${open}${inner}${close}`;
}
if (T === 'CODE') { const txt = node.textContent || ''; return txt.trim() ? wrapInlineCode(txt) : ''; }
if (T === 'A') { const textContent = processChildrenInline(node, { ...ctx, escapeText: false }).trim(), text = textContent || (node.getAttribute('href') || ''), href = hrefForA(node, ctx.baseUri); return href ? mdLink(text, href, ctx.inTable) : escapeLinkLabel(text, ctx); }
if (T === 'IMG') { const wtex = wikipediaImgToTex(node); if (wtex) return wtex; const alt = escapeBracketText((node.getAttribute('alt') || '').trim()), u = absUrl(pickImgSrc(node), ctx.baseUri); return u ? `})` : (alt || ''); }
if (T === 'SUB') return `<sub>${processChildrenInline(node, ctx).trim()}</sub>`;
if (T === 'SUP') return `<sup>${processChildrenInline(node, ctx).trim()}</sup>`;
if (T === 'KBD') return `<kbd>${processChildrenInline(node, ctx).trim()}</kbd>`;
if (T === 'U') return `<u>${processChildrenInline(node, ctx)}</u>`;
if (T === 'MARK') return `<mark>${processChildrenInline(node, ctx)}</mark>`;
if (T === 'MATH') return processMathML(node);
if (node.matches?.('.katex,.katex-display,mjx-container,.MathJax,span.MathJax,script[type^="math/tex"]')) { if (node.closest?.('pre,code')) return node.textContent || ''; let tex = extractTex(node); if (!tex) return ''; const block = isDisplayMath(node, tex); if (block && S.get('stripCommonIndentInBlockMath')) tex = stripCommonIndent(tex); return block ? `<br>$$ ${tex} $$<br>` : `$${tex}$`; }
if (BLOCK_TAGS.has(T)) return processChildrenInline(node, ctx).trim();
return processChildrenInline(node, ctx);
} catch (e) { console.warn('[mdltx] mdInline error:', e); return ''; }
}
// ─────────────────────────────────────────────────────────────
// § Markdown 轉換(區塊)
// ─────────────────────────────────────────────────────────────
function md(node, ctx) {
ctx = normalizeCtx(ctx); if (!node || isOurUI(node) || isHiddenInClone(node)) return '';
try {
if (node.nodeType === 3) { const raw = node.nodeValue || '', ptag = node.parentElement?.tagName || ''; if (ptag === 'PRE' || ptag === 'CODE') return raw; if (/^\s+$/.test(raw)) return wsTextNodeToSpace(node) || (INLINE_PARENT_TAGS.has(ptag) ? ' ' : ''); return escapeMarkdownText(String(raw).replace(/\s+/g, ' '), ctx); }
if (node.nodeType !== 1) return '';
const T = node.tagName;
if (/^(SCRIPT|STYLE|NOSCRIPT|MJX-ASSISTIVE-MML|TEMPLATE)$/.test(T)) return '';
if (S.get('ignoreNav') && isNavLike(node)) return '';
if (node.classList?.contains('mwe-math-element')) { const wiki = processWikipediaMath(node); if (wiki !== null) return wiki; }
const unknownResult = processUnknownEmptyTag(node, ctx); if (unknownResult !== null) return unknownResult;
if (T === 'SLOT') { let r = ''; for (const n of (node.assignedNodes?.({ flatten: true }) || [])) r = smartConcat(r, md(n, ctx)); return r; }
if (T === 'SVG') { const text = processSvg(node); return text ? `\n\n${text}\n\n` : ''; }
if (T === 'CANVAS') { const fb = node.textContent?.trim(); return fb ? `\n\n${fb}\n\n` : ''; }
if (T === 'MATH') return processMathML(node);
if (T === 'IFRAME') { const pre = node.getAttribute('data-mdltx-iframe-md'); return pre ? `\n\n${pre}\n\n` : ''; }
if (T.includes('-') && S.get('extractShadowDOM')) { const sc = extractShadowContent(node, ctx); if (sc) return sc; }
if (T === 'FIGURE') return figureToMd(node, ctx);
if (T === 'DL') return dlToMd(node, ctx);
if (T === 'DIV' && node.hasAttribute('data-code-block')) { const header = node.querySelector(':scope > div:first-child'), lang = (header?.textContent || '').trim().toLowerCase(), codeEl = node.querySelector('pre code') || node.querySelector('pre'), content = (codeEl?.textContent || '').replace(/\n+$/g, ''), fence = chooseFence(content); return `\n\n${fence}${lang}\n${content}\n${fence}\n\n`; }
if (T === 'DETAILS') { const isOpen = node.hasAttribute('open'), summary = node.querySelector(':scope > summary'), summaryText = summary ? mdInline(summary, ctx).trim() : t('detailsDefaultSummary');
if (S.get('detailsStrategy') === 'strict-visual' && !isOpen) return `\n\n<details>\n<summary>${summaryText}</summary>\n\n</details>\n\n`;
let inner = ''; for (const ch of Array.from(node.childNodes)) if (!(ch.nodeType === 1 && ch.tagName === 'SUMMARY')) inner += md(ch, ctx);
return `\n\n<details${isOpen ? ' open' : ''}>\n<summary>${summaryText}</summary>\n\n${trimNewlinesOnly(inner)}\n\n</details>\n\n`; }
if (T === 'TABLE') return `\n\n${tableToMd(node, ctx)}\n\n`;
if (T === 'PRE') { const cd = node.querySelector('code'), targetEl = cd || node; let lang = targetEl.getAttribute('data-mdltx-lang'); if (lang === null || lang === undefined) lang = detectLang(targetEl); lang = lang || '';
const body = (targetEl).textContent?.replace(/\n+$/g, '') || '', fence = chooseFence(body); return `\n\n${fence}${lang}\n${body}\n${fence}\n\n`; }
if (T === 'INPUT') { const type = (node.getAttribute('type') || '').toLowerCase(); if (type === 'checkbox') return (node.checked || node.defaultChecked || node.getAttribute('checked') !== null) ? '[x] ' : '[ ] '; return ''; }
if (T === 'CODE' && node.parentElement?.tagName !== 'PRE') { const txt = node.textContent || ''; return txt.trim() ? wrapInlineCode(txt) : ''; }
if (T === 'RUBY') return processRuby(node);
if (/^H[1-6]$/.test(T)) { const lvl = parseInt(T.slice(1), 10), inner = processChildren(node, ctx).trim(); return inner ? `\n\n${'#'.repeat(lvl)} ${inner}\n\n` : ''; }
if (T === 'BR') return '<br>\n';
if (T === 'HR') return `\n\n${getHorizontalRule()}\n\n`;
if (T === 'A') { const textContent = processChildren(node, { ...ctx, escapeText: false }).trim(), text = textContent || (node.getAttribute('href') || ''), href = hrefForA(node, ctx.baseUri); return href ? mdLink(text, href, ctx.inTable) : escapeLinkLabel(text, ctx); }
if (T === 'IMG') { const wtex = wikipediaImgToTex(node); if (wtex) return wtex; const alt = escapeBracketText((node.getAttribute('alt') || '').trim()), u = absUrl(pickImgSrc(node), ctx.baseUri); return u ? `})` : (alt || ''); }
if (/^(STRONG|B|EM|I|DEL|S)$/.test(T)) return processInlineFormat(node, T, ctx);
if (T === 'Q') {
const inner = processChildren(node, ctx).trim();
if (!inner) return '';
const lang = detectLanguage();
const [open, close] = lang.startsWith('zh') ? ['「', '」'] : ['"', '"'];
return `${open}${inner}${close}`;
}
if (T === 'SUB') return `<sub>${processChildren(node, ctx).trim()}</sub>`;
if (T === 'SUP') return `<sup>${processChildren(node, ctx).trim()}</sup>`;
if (T === 'KBD') return `<kbd>${processChildren(node, ctx).trim()}</kbd>`;
if (T === 'U') return `<u>${processChildren(node, ctx)}</u>`;
if (T === 'MARK') return `<mark>${processChildren(node, ctx)}</mark>`;
if (node.matches?.('.katex,.katex-display,mjx-container,.MathJax,span.MathJax,script[type^="math/tex"]')) { if (node.closest?.('pre,code')) return node.textContent || ''; let tex = extractTex(node); if (!tex) return ''; const block = isDisplayMath(node, tex); if (block && S.get('stripCommonIndentInBlockMath')) tex = stripCommonIndent(tex); return block ? `\n\n$$\n${tex}\n$$\n\n` : `$${tex}$`; }
if (T === 'BLOCKQUOTE') { let inner = processChildren(node, ctx).replace(/\n{3,}/g, '\n\n').trim().replace(/^\s{4}([-*+] |\d+\. )/gm, '$1'); return `\n\n${inner.split('\n').map(l => l.trim() === '' ? '>' : `> ${l}`).join('\n')}\n\n`; }
// ═══════════════════════════════════════════════════════════
// 🔧 修復:處理 UL/OL 元素(包含非 LI 子元素的情況)
// Arena 等現代平台使用 OL/UL 作為 flex 容器,子元素是 DIV
// ═══════════════════════════════════════════════════════════
if (T === 'UL' || T === 'OL') {
const ordered = T === 'OL';
let idx = 1, out = '';
const children = Array.from(node.children);
// 統計子元素類型
const liChildren = children.filter(c => c.tagName === 'LI');
const nonLiChildren = children.filter(c => c.tagName !== 'LI' && c.nodeType === 1);
// 情況 1:標準列表(只有 LI 子元素)
if (liChildren.length > 0 && nonLiChildren.length === 0) {
for (const li of liChildren) {
out += renderLi(li, ctx.depth || 0, ordered ? idx++ : 0, ctx);
}
return out.trim() ? `\n\n${out}\n\n` : '';
}
// 情況 2:純容器用途(沒有 LI 子元素)—— Arena 的主要模式
// 例如 Arena 使用 <ol class="flex flex-col-reverse"> 包裹 <div> 訊息
if (liChildren.length === 0 && nonLiChildren.length > 0) {
// 檢測 flex-col-reverse(Arena 特徵)並反轉順序
const isReversed = node.classList?.contains('flex-col-reverse') ?? false;
const childrenToProcess = isReversed ? [...nonLiChildren].reverse() : nonLiChildren;
let result = '';
for (const child of childrenToProcess) {
const childContent = md(child, ctx);
if (childContent.trim()) {
result = smartConcat(result, childContent);
}
}
return result.trim() ? `\n\n${result}\n\n` : '';
}
// 情況 3:混合內容(同時有 LI 和非 LI 子元素)
const isReversedMixed = node.classList?.contains('flex-col-reverse') ?? false;
const mixedChildren = isReversedMixed ? [...children].reverse() : children;
for (const child of mixedChildren) {
if (child.tagName === 'LI') {
out += renderLi(child, ctx.depth || 0, ordered ? idx++ : 0, ctx);
} else if (child.nodeType === 1) {
const childContent = md(child, ctx).trim();
if (childContent) {
out += `\n${childContent}\n`;
}
}
}
return out.trim() ? `\n\n${out}\n\n` : '';
}
if (T === 'P') { const inner = processChildren(node, ctx).trim(); return inner ? `\n\n${inner}\n\n` : ''; }
if (/^(DIV|SECTION|ARTICLE|MAIN|NAV|HEADER|FOOTER|ASIDE)$/.test(T)) return `\n\n${processChildren(node, ctx)}\n\n`;
return processChildren(node, ctx);
} catch (e) { console.warn('[mdltx] md error:', e); return ''; }
}
function renderLi(li, depth, olIndex, ctx) {
const maxDepth = 10;
const effectiveDepth = Math.min(depth, maxDepth);
ctx = normalizeCtx(ctx); const indent = ' '.repeat(effectiveDepth * 4), marker = getListMarker(), prefix = olIndex ? `${olIndex}. ` : `${marker} `;
let contentParts = '', nestedParts = '';
try { for (const ch of Array.from(li.childNodes)) { if (ch.nodeType === 1 && (ch.tagName === 'UL' || ch.tagName === 'OL')) nestedParts += md(ch, { ...ctx, depth: Math.min(depth + 1, maxDepth) }); else contentParts += md(ch, ctx); }
const content = String(contentParts).replace(/\n{3,}/g, '\n\n').trim(), nested = nestedParts?.trim() ? trimNewlinesOnly(nestedParts) : '';
if (!content && !nested) return '';
const lines = content ? content.split('\n') : ['']; let out = `${indent}${prefix}${lines[0] || ''}\n`;
for (let i = 1; i < lines.length; i++) out += `${indent} ${lines[i]}\n`;
if (nested) out += `${nested}\n`;
return out;
} catch (e) { console.warn('[mdltx] renderLi error:', e); return ''; }
}
// ─────────────────────────────────────────────────────────────
// § 公式 placeholder 與輸出正規化
// ─────────────────────────────────────────────────────────────
function replaceMathWithPlaceholders(container) {
const map = {}; let id = 0; const nonce = generateNonce();
try { const selector = '.katex,.katex-display,mjx-container,.MathJax,span.MathJax,script[type^="math/tex"],math,.mwe-math-element,img.mwe-math-fallback-image-inline,img.mwe-math-fallback-image-display';
container.querySelectorAll(selector).forEach(el => {
if (el.closest('pre,code') || el.closest?.('[data-mdltx-ui="1"]') || el.closest?.('[data-mdltx-hidden="1"]')) return;
let out = '';
if (el.classList?.contains('mwe-math-element')) { out = processWikipediaMath(el) ?? ''; if (out) { const key = makePlaceholder('MATH', nonce, id++); map[key] = out; const sp = document.createElement('span'); sp.textContent = key; el.replaceWith(sp); } return; }
if (el.closest?.('.mwe-math-element')) return;
if (el.tagName === 'IMG') { out = wikipediaImgToTex(el) || ''; if (out) { const key = makePlaceholder('MATH', nonce, id++); map[key] = out; const sp = document.createElement('span'); sp.textContent = key; el.replaceWith(sp); } return; }
if (el.tagName === 'MATH') { out = processMathML(el) || ''; if (out) { const key = makePlaceholder('MATH', nonce, id++); map[key] = out; const sp = document.createElement('span'); sp.textContent = key; el.replaceWith(sp); } return; }
let tex0 = extractTex(el); if (!tex0) return;
const block = isDisplayMath(el, tex0); let tex = (block && S.get('stripCommonIndentInBlockMath')) ? stripCommonIndent(tex0) : tex0;
const key = makePlaceholder('MATH', nonce, id++); map[key] = block ? `\n\n$$\n${tex}\n$$\n\n` : `$${tex}$`;
const sp = document.createElement('span'); sp.textContent = key; el.replaceWith(sp);
});
} catch (e) { console.warn('[mdltx] replaceMathWithPlaceholders error:', e); }
return map;
}
function normalizeOutput(mdText) {
try { let s = String(mdText || ''); const blocks = {}, nonce = generateNonce(); let bid = 0;
s = s.replace(/(^|\n)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g, (m, p1) => { const key = makePlaceholder('CODEBLOCK', nonce, bid++); blocks[key] = m.slice(p1.length); return p1 + key; });
s = s.replace(/\u00a0/g, ' ').replace(/[\u200B\u2060\uFEFF]/g, '').replace(/([^\n \t])[ \t]+\n/g, '$1\n').replace(/\n{3,}/g, '\n\n').trim();
for (const [k, v] of Object.entries(blocks)) s = s.split(k).join(v);
return s.trim();
} catch (e) { console.warn('[mdltx] normalizeOutput error:', e); return mdText; }
}
// ─────────────────────────────────────────────────────────────
// § 選取與文章偵測
// ─────────────────────────────────────────────────────────────
/**
* 計算元素的可見文字長度(排除 script/style/noscript)
* 修正 Arena 等 Next.js 網站的 articleMinRatio 計算
*/
function getVisibleTextLength(el) {
if (!el) return 0;
let len = 0;
try {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode;
const parent = node.parentElement;
// 跳過不可見內容的父元素
if (parent && /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|MJX-ASSISTIVE-MML)$/i.test(parent.tagName)) continue;
// 跳過我們自己的 UI
if (parent && (parent.id === 'mdltx-ui-host' || parent.closest?.('[data-mdltx-ui="1"]'))) continue;
const text = node.nodeValue || '';
if (text.trim()) len += text.length;
}
} catch (e) {
// 降級方案
try {
const clone = el.cloneNode(true);
clone.querySelectorAll('script, style, noscript, template').forEach(n => n.remove());
len = (clone.textContent || '').trim().length;
} catch {
len = (el.textContent || '').trim().length;
}
}
return len;
}
function getSelection() {
try { const sel = window.getSelection?.(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return { hasSelection: false, range: null };
const range = sel.getRangeAt(0), fragment = range.cloneContents(); if (!fragment.hasChildNodes()) return { hasSelection: false, range: null };
const hasMeaningful = (fragment.textContent?.trim() || '') || fragment.querySelector?.('img,svg,math,.katex,mjx-container,table,pre,code,.mwe-math-element,img.mwe-math-fallback-image-inline,img.mwe-math-fallback-image-display');
return hasMeaningful ? { hasSelection: true, range } : { hasSelection: false, range: null };
} catch { return { hasSelection: false, range: null }; }
}
function hasSelection() { return getSelection().hasSelection; }
function getSelectionRange() { return getSelection().range; }
function findArticleRoot() {
const candidates = [];
try {
const startedAt = performance?.now?.() || Date.now();
// ═══════════════════════════════════════════════════════════
// 🔧 增強的選擇器列表(包含 AI 聊天平台特定容器)
// ═══════════════════════════════════════════════════════════
const selectors = [
// 標準語義元素
'article', 'main', '[role="main"]', '#content', '#main', '#article', '#post',
'.content', '.main', '.article', '.post', '.entry', '.markdown-body',
// AI 聊天平台特定選擇器(Arena/Claude/ChatGPT/Grok)
'#chat-area', '[id*="chat"]', '[class*="chat-area"]',
'[class*="conversation"]', '[class*="messages"]', '[class*="chat-messages"]',
'[class*="message-list"]', '[class*="response"]',
// Arena 特定:使用 OL 作為訊息容器
'ol[class*="flex"][class*="flex-col"]',
// Claude 特定
'[class*="prose"]', '[class*="markdown"]'
];
for (const sel of selectors) {
try { document.querySelectorAll(sel).forEach(el => candidates.push(el)); } catch {}
}
// 補充通用選擇器
Array.from(document.querySelectorAll('section,div')).slice(0, 250).forEach(el => candidates.push(el));
const isBad = el => {
if (!el || el.nodeType !== 1) return true;
if (/^(NAV|ASIDE|FOOTER|HEADER|FORM)$/.test(el.tagName)) return true;
if (el.closest('nav,aside,footer,header,form')) return true;
const role = (el.getAttribute?.('role') || '').toLowerCase();
if (/^(navigation|banner|contentinfo|complementary)$/.test(role)) return true;
// 排除輸入區域
if (el.closest('[class*="input-area"]') || el.closest('[class*="composer"]')) return true;
return false;
};
const score = el => {
if (!el || isBad(el)) return -1e9;
// 使用可見文字長度(排除 script 內容)
const len = getVisibleTextLength(el);
if (len < 200) return -1e9;
const paragraphs = el.querySelectorAll('p').length || 0;
const preCodes = el.querySelectorAll('pre,code').length || 0;
const links = el.querySelectorAll('a').length || 0;
// AI 聊天平台加分指標
const messages = el.querySelectorAll('[class*="message"],[class*="prose"],[class*="response"],[class*="assistant"],[class*="user"]').length || 0;
// Arena 特定:OL 內的 DIV 訊息
const olDivs = el.querySelectorAll('ol > div').length || 0;
return len + paragraphs * 120 + preCodes * 60 + messages * 100 + olDivs * 80 - links * 30;
};
// ═══ 兩階段篩選:先用快速指標過濾,再對候選做精確計算 ═══
// 第一階段:快速估算,取 top-30
const quickScored = candidates
.map(el => {
if (!el || el.nodeType !== 1 || isBad(el)) return null;
// 用 textContent.length 快速估算(可能包含 script/style 內容,但做為粗篩足夠)
const quickLen = (el.textContent || '').length;
if (quickLen < 100) return null;
return { el, quickLen };
})
.filter(Boolean)
.sort((a, b) => b.quickLen - a.quickLen)
.slice(0, 30);
// 第二階段:精確計算,僅對 top-30 使用完整的 score 函數
let best = null, bestScore = -1e9;
for (const { el } of quickScored) {
const sc = score(el);
if (sc > bestScore) { bestScore = sc; best = el; }
}
const elapsed = (performance?.now?.() || Date.now()) - startedAt;
diagLog('Article root scan', { candidates: candidates.length, shortlisted: quickScored.length, bestScore, elapsedMs: Math.round(elapsed) });
return best || document.body;
} catch { return document.body; }
}
function isArticleTooSmall(el) {
try {
// 使用可見文字長度(排除 script/style 內容)
const a = getVisibleTextLength(el);
const b = getVisibleTextLength(document.body) || 1;
// 對 AI 聊天平台使用更寬鬆的判斷
if (isAIChatPlatform()) {
// 只要有足夠的內容就不算太小
return a < Math.min(S.get('articleMinChars'), 300);
}
return a < S.get('articleMinChars') || a / b < S.get('articleMinRatio');
} catch { return true; }
}
function decideModeNoSelection() { const m = String(S.get('noSelectionMode') || 'page'); return m === 'article' ? 'article' : 'page'; }
// ─────────────────────────────────────────────────────────────
// § 主要流程
// ─────────────────────────────────────────────────────────────
function isWikipediaHost() { try { return /(^|\.)wikipedia\.org$/i.test(location.hostname); } catch { return false; } }
function cleanupWikipediaUiNoise(root) { if (!isWikipediaHost() || !root?.querySelectorAll) return; try { root.querySelectorAll('span.mw-editsection, span[class*="mw-editsection"]').forEach(n => n.remove()); } catch (e) { console.warn('[mdltx] cleanupWikipediaUiNoise error:', e); } }
async function makeRoot(mode) {
try {
const rng = mode === 'selection' ? getSelectionRange() : null;
const scope = rng ? ((rng.commonAncestorContainer.nodeType === 1 ? rng.commonAncestorContainer : rng.commonAncestorContainer.parentElement) || document.body) : document.body;
// 抓取前準備(臨時展開第三方腳本折疊的內容)
const restoreActions = prepareForCapture(scope);
try {
const hiddenTagged = annotateHidden(scope), iframeTagged = annotateIframes(scope), formatTagged = annotateFormatBoundaries(scope), codeBlockTagged = annotateCodeBlockLanguages(scope);
await waitForMathJax(scope);
const mjTagged = annotateMathJax(scope);
let root, actualMode = mode;
if (mode === 'selection' && rng) { const box = document.createElement('div'); box.appendChild(rng.cloneContents()); root = box; }
else if (mode === 'article') { const art = findArticleRoot(); if (!art || art === document.body || isArticleTooSmall(art)) { root = document.body.cloneNode(true); actualMode = 'page'; } else root = art.cloneNode(true); }
else root = document.body.cloneNode(true);
cleanupAnnotations(mjTagged, 'data-mdltx-tex'); cleanupAnnotations(mjTagged, 'data-mdltx-display');
cleanupAnnotations(hiddenTagged, 'data-mdltx-hidden'); cleanupAnnotations(iframeTagged, 'data-mdltx-iframe-md');
cleanupAnnotations(formatTagged, 'data-mdltx-block'); cleanupAnnotations(codeBlockTagged, 'data-mdltx-lang');
cleanupWikipediaUiNoise(root);
// 清理第三方 UI 元素
cleanupThirdPartyUI(root);
try {
root.querySelectorAll?.('[data-mdltx-ui="1"],#mdltx-ui-host').forEach(n => n.remove());
// ═══════════════════════════════════════════════════════════
// 🔧 AI 聊天平台特殊處理:不移除被標記的元素,只清理標記
// 這是修復 Arena 等平台無法抓取內容的核心修改
// ═══════════════════════════════════════════════════════════
if (isAIChatPlatform()) {
// 對 AI 聊天平台:只清理標記,保留所有元素
root.querySelectorAll?.('[data-mdltx-hidden="1"]').forEach(n => {
n.removeAttribute('data-mdltx-hidden');
});
} else {
// 對一般網站:移除真正隱藏的元素(原有行為)
root.querySelectorAll?.('[data-mdltx-hidden="1"]').forEach(n => {
if (isMathInfra(n)) { n.removeAttribute('data-mdltx-hidden'); return; }
n.remove();
});
}
root.querySelectorAll?.('[data-mdltx-processed]').forEach(n => n.removeAttribute('data-mdltx-processed'));
} catch {}
return { root, actualMode };
} finally {
// 恢復第三方腳本的原狀
restoreAfterCapture(restoreActions);
}
} catch (e) { console.error('[mdltx] makeRoot error:', e); throw e; }
}
async function generateMarkdown(mode) {
try { const waitMs = S.get('waitBeforeCaptureMs'); if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
const idleMs = S.get('waitDomIdleMs'); if (idleMs > 0) await waitForDomIdle(idleMs);
if (mode === 'selection' && !hasSelection()) mode = decideModeNoSelection();
let { root, actualMode } = await makeRoot(mode);
const mathMap = replaceMathWithPlaceholders(root);
const ctx = { depth: 0, escapeText: S.get('escapeMarkdownChars'), inTable: false, baseUri: document.baseURI };
let out = md(root, ctx);
// 及時釋放 DOM 克隆以減輕記憶體壓力
root = null;
for (const k of Object.keys(mathMap)) out = out.split(k).join(mathMap[k]);
out = normalizeOutput(out);
return { markdown: out, actualMode, length: out.length };
} catch (e) { console.error('[mdltx] generateMarkdown error:', e); throw e; }
}
async function setClipboardText(text) {
try { GM_setClipboard(text); return true; }
catch (e) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {} throw e; }
}
async function copyMarkdown(mode) {
try { const result = await generateMarkdown(mode); await setClipboardText(result.markdown); return result; }
catch (e) { console.error('[mdltx] copyMarkdown error:', e); throw e; }
}
// ─────────────────────────────────────────────────────────────
// § 快捷鍵與選單
// ─────────────────────────────────────────────────────────────
function installHotkey() {
window.addEventListener('keydown', async e => {
try {
if (e.repeat) return;
const key = (e.key || '').toLowerCase();
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
const isModifierMatch = S.get('hotkeyAlt') === e.altKey && S.get('hotkeyCtrl') === e.ctrlKey && S.get('hotkeyShift') === e.shiftKey && !e.metaKey;
// 主快捷鍵
if (S.get('hotkeyEnabled') && isModifierMatch && key === S.get('hotkeyKey').toLowerCase()) {
e.preventDefault(); e.stopPropagation();
const mode = hasSelection() ? 'selection' : decideModeNoSelection();
if (ui) await ui.handleCopy(mode); else await copyMarkdown(mode);
return;
}
// 元素選取快捷鍵
if (S.get('elementPickerEnabled') && isModifierMatch && key === S.get('elementPickerHotkey').toLowerCase()) {
e.preventDefault(); e.stopPropagation();
if (ui) ui.startElementPicker();
return;
}
// 預覽快捷鍵
if (S.get('previewEnabled') && isModifierMatch && key === S.get('previewHotkey').toLowerCase()) {
e.preventDefault(); e.stopPropagation();
if (ui) await ui.handlePreview();
return;
}
} catch (err) { console.error('[mdltx] Hotkey error:', err); }
}, true);
}
function installMenu() {
try {
GM_registerMenuCommand('📋 ' + t('copySelection'), async () => { try { if (ui) await ui.handleCopy('selection'); else await copyMarkdown('selection'); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
GM_registerMenuCommand('📰 ' + t('copyArticle'), async () => { try { if (ui) await ui.handleCopy('article'); else await copyMarkdown('article'); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
GM_registerMenuCommand('🌐 ' + t('copyPage'), async () => { try { if (ui) await ui.handleCopy('page'); else await copyMarkdown('page'); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
GM_registerMenuCommand('💾 ' + t('downloadMd'), async () => { try { if (ui) await ui.handleDownload(); else { const mode = hasSelection() ? 'selection' : decideModeNoSelection(); const result = await generateMarkdown(mode); downloadAsFile(result.markdown, generateFilename()); } } catch (e) { console.error('[mdltx] Menu command error:', e); } });
GM_registerMenuCommand('⚙️ ' + t('settings'), () => { try { if (ui) ui.showSettings(); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
// 新增:元素選取
if (S.get('elementPickerEnabled')) {
GM_registerMenuCommand('🎯 ' + t('pickElement'), () => { try { if (ui) ui.startElementPicker(); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
}
// 新增:預覽模式
if (S.get('previewEnabled')) {
GM_registerMenuCommand('👁️ ' + t('previewCopy'), async () => { try { if (ui) await ui.handlePreview(); } catch (e) { console.error('[mdltx] Menu command error:', e); } });
}
} catch (e) { console.warn('[mdltx] Failed to register menu commands:', e); }
}
// ─────────────────────────────────────────────────────────────
// § 初始化
// ─────────────────────────────────────────────────────────────
let ui = null;
function init() {
try {
migrateSettings();
ui = new UIManager();
ui.init();
installHotkey();
installMenu();
console.log('[mdltx] Copy MD + LaTeX v3.2.4 initialized.');
} catch (e) { console.error('[mdltx] Initialization failed:', e); }
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else setTimeout(init, 0);
})();