// ==UserScript== // @name Markdown 阅读器 & 编辑器 (分屏实时预览) – 可拖拽工具栏(常规字体+切换) // @namespace http://tampermonkey.net/ // @version 5.7 // @description 拖入 .md 文件,分屏预览+编辑,样式按钮平铺,面板可拖拽,所有按钮点击切换,加粗预览更明显,左侧预览区支持文字复制,自动清除尾部垃圾行(逐行删除),启动即分屏 // @author MRBANK (modified by xizi) // @match file:///* // @grant GM_addStyle // @run-at document-start // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHRleHQgeD0iMTIiIHk9IjE2LjUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9ImJvbGQiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM1MGZhN2IiPk08L3RleHQ+PC9zdmc+ // @license All Rights Reserved // ==/UserScript== (function() { 'use strict'; // ---------- 全局状态 ---------- let currentSource = ''; let currentFileName = 'document'; // ---------- DOM 引用 ---------- const panel = document.createElement('div'); panel.id = 'md-control-panel'; // 样式按钮定义(增加 data-action 属性) const styleActions = [ { label: 'H1', action: 'h1' }, { label: 'H2', action: 'h2' }, { label: 'H3', action: 'h3' }, { label: 'B', action: 'bold' }, { label: 'I', action: 'italic' }, { label: '`', action: 'code' }, { label: '代码块', action: 'codeblock' }, { label: '引用', action: 'quote' }, { label: '列表', action: 'ul' }, { label: '有序', action: 'ol' }, { label: '链接', action: 'link' }, { label: '图片', action: 'image' }, { label: '🎨', action: 'color' } ]; // 创建按钮,只设置类、文本和 data-action,不绑定事件 styleActions.forEach(item => { const btn = document.createElement('button'); btn.className = 'md-btn-control'; btn.textContent = item.label; btn.dataset.action = item.action; panel.appendChild(btn); }); // 保存按钮 const btnSave = document.createElement('button'); btnSave.className = 'md-btn-control'; btnSave.textContent = '保存'; panel.appendChild(btnSave); const dropOverlay = document.createElement('div'); dropOverlay.id = 'md-drop-overlay'; dropOverlay.innerHTML = `
📄
将 Markdown 文件拖放到此处
支持 .md .markdown .txt
`; // ---------- 分屏元素 ---------- let previewPane = null; let textarea = null; let splitter = null; let container = null; // ---------- 样式 ---------- GM_addStyle(` #md-control-panel { position: fixed; left: calc(100vw - 12px - auto); top: 12px; z-index: 2147483647; display: flex; flex-wrap: wrap; gap: 4px; background: #2b2b2b; padding: 6px 8px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; border: 1px solid #444; max-width: 90vw; justify-content: flex-end; cursor: grab; user-select: none; } #md-control-panel.dragging { cursor: grabbing; } .md-btn-control { background: transparent; color: #999; border: none; padding: 4px 12px; font-size: 13px; font-weight: 500; cursor: pointer; border-radius: 6px; transition: all 0.2s ease-in-out; line-height: 1.5; white-space: nowrap; } .md-btn-control:hover { color: #fff; background: #3a3a3a; } .md-btn-control.active { background: #555; color: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } #md-drop-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 2147483646; display: flex; align-items: center; justify-content: center; background: rgba(40, 42, 54, 0.95); color: #f8f8f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; transition: opacity 0.3s; pointer-events: none; opacity: 0; } #md-drop-overlay.show { opacity: 1; pointer-events: auto; } .md-drop-content { text-align: center; border: 3px dashed #50fa7b; padding: 60px 80px; border-radius: 24px; background: rgba(30, 31, 41, 0.8); } .md-drop-icon { font-size: 72px; margin-bottom: 20px; } .md-drop-text { font-size: 28px; font-weight: 600; margin-bottom: 10px; } .md-drop-hint { font-size: 16px; color: #999; } body { margin: 0; padding: 0; background: #1e1f29; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; } .md-split-container { display: flex; height: 100vh; width: 100vw; background: #1e1f29; overflow: hidden; user-select: none; } .md-preview-pane, .md-editor-pane { flex: 1 1 0; height: 100%; overflow: auto; padding: 20px; box-sizing: border-box; } .md-preview-pane { background: #282a36; color: #f8f8f2; overflow-y: auto; /* 允许选择文本,便于复制 */ user-select: text; -webkit-user-select: text; -moz-user-select: text; } .md-preview-pane .md-rendered { max-width: 900px; margin: 0 auto; line-height: 1.6; font-weight: 400; } .md-editor-pane { background: #282a36; border-left: 1px solid #444; } .md-editor-pane textarea { width: 100%; height: 100%; background: transparent; color: #f8f8f2; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 14px; line-height: 1.6; border: none; resize: none; outline: none; padding: 0; white-space: pre-wrap; word-wrap: break-word; tab-size: 4; } .md-editor-pane textarea:focus { box-shadow: none; } .md-splitter { flex: 0 0 6px; background: #444; cursor: col-resize; transition: background 0.15s; position: relative; z-index: 10; } .md-splitter:hover, .md-splitter.dragging { background: #50fa7b; } .md-splitter::after { content: '⋮'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #888; font-size: 16px; line-height: 1; } /* 预览区标题样式 */ .md-preview-pane .md-rendered h1, .md-preview-pane .md-rendered h2, .md-preview-pane .md-rendered h3, .md-preview-pane .md-rendered h4, .md-preview-pane .md-rendered h5, .md-preview-pane .md-rendered h6 { color: #bd93f9; margin-top: 24px; margin-bottom: 16px; line-height: 1.25; } .md-preview-pane .md-rendered h1 { font-size: 2em; border-bottom: 1px solid #444; padding-bottom: .3em; } .md-preview-pane .md-rendered h2 { font-size: 1.5em; border-bottom: 1px solid #444; padding-bottom: .3em; } .md-preview-pane .md-rendered p { margin-bottom: 16px; } .md-preview-pane .md-rendered a { color: #8be9fd; text-decoration: none; } .md-preview-pane .md-rendered a:hover { text-decoration: underline; } .md-preview-pane .md-rendered code { background: #44475a; padding: 0.2em 0.4em; margin: 0; font-size: 85%; border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; color: #50fa7b; } .md-preview-pane .md-rendered pre { background: #1e1f29; border-radius: 6px; padding: 16px; overflow: auto; line-height: 1.45; font-size: 14px; } .md-preview-pane .md-rendered pre code { background: transparent; padding: 0; color: #f8f8f2; } .md-hl-kw { color: #ff79c6; } .md-hl-str { color: #f1fa8c; } .md-hl-num { color: #bd93f9; } .md-hl-cmt { color: #6272a4; font-style: italic; } .md-preview-pane .md-rendered blockquote { border-left: 4px solid #bd93f9; padding-left: 16px; margin: 0 0 16px 0; color: #b0b0b0; } .md-preview-pane .md-rendered table { border-collapse: collapse; width: 100%; margin-bottom: 16px; } .md-preview-pane .md-rendered table th, .md-preview-pane .md-rendered table td { border: 1px solid #444; padding: 8px 12px; } .md-preview-pane .md-rendered table th { background: #44475a; font-weight: 400; } .md-preview-pane .md-rendered table tr:nth-child(even) { background: rgba(40, 42, 54, 0.5); } .md-preview-pane .md-rendered img { max-width: 100%; border-radius: 4px; margin: 10px 0; } .md-preview-pane .md-rendered hr { border: 0; height: 1px; background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0)); margin: 2em 0; } /* 增强加粗样式,使其更明显 */ .md-preview-pane .md-rendered strong { font-weight: 700; color: #ffffff; } .md-preview-pane::-webkit-scrollbar, .md-editor-pane::-webkit-scrollbar { width: 8px; } .md-preview-pane::-webkit-scrollbar-track, .md-editor-pane::-webkit-scrollbar-track { background: #1e1f29; } .md-preview-pane::-webkit-scrollbar-thumb, .md-editor-pane::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; } .md-preview-pane::-webkit-scrollbar-thumb:hover, .md-editor-pane::-webkit-scrollbar-thumb:hover { background: #777; } `); // ---------- 面板拖拽逻辑 ---------- let isDragging = false; let dragStartX = 0, dragStartY = 0; let panelStartLeft = 0, panelStartTop = 0; function initPanelPosition() { const saved = localStorage.getItem('mdPanelPosition'); if (saved) { try { const pos = JSON.parse(saved); if (pos.left !== undefined && pos.top !== undefined) { panel.style.left = pos.left + 'px'; panel.style.top = pos.top + 'px'; return; } } catch (e) {} } const panelWidth = panel.offsetWidth || 200; const defaultLeft = window.innerWidth - panelWidth - 12; panel.style.left = defaultLeft + 'px'; panel.style.top = '12px'; } function savePanelPosition() { const rect = panel.getBoundingClientRect(); const pos = { left: rect.left, top: rect.top }; localStorage.setItem('mdPanelPosition', JSON.stringify(pos)); } function onPanelMouseDown(e) { if (e.target.closest('.md-btn-control')) return; isDragging = true; const rect = panel.getBoundingClientRect(); panelStartLeft = rect.left; panelStartTop = rect.top; dragStartX = e.clientX; dragStartY = e.clientY; panel.classList.add('dragging'); document.body.style.cursor = 'grabbing'; e.preventDefault(); } function onDocumentMouseMove(e) { if (!isDragging) return; const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; let newLeft = panelStartLeft + dx; let newTop = panelStartTop + dy; const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; newLeft = Math.max(0, Math.min(window.innerWidth - panelWidth, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - panelHeight, newTop)); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; } function onDocumentMouseUp(e) { if (isDragging) { isDragging = false; panel.classList.remove('dragging'); document.body.style.cursor = ''; savePanelPosition(); } } // ---------- 工具:清洗尾部垃圾行(逐行检查) ---------- function cleanTrailingGarbage(text) { if (!text) return text; const lines = text.split('\n'); // 定义垃圾关键词列表(完全匹配一行,忽略大小写和首尾空格) const garbageWords = [ 'H1', 'H2', 'H3', 'B', 'I', '`', '代码块', '引用', '列表', '有序', '链接', '图片', '🎨', '保存', '📄', '将 Markdown 文件拖放到此处', '支持 .md .markdown .txt' ]; // 转义正则特殊字符 const escapedWords = garbageWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp('^\\s*(' + escapedWords.join('|') + ')\\s*$', 'i'); // 从末尾开始向前查找,删除所有连续匹配的行 let endIndex = lines.length; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (regex.test(line)) { endIndex = i; } else { break; } } if (endIndex < lines.length) { // 删除从 endIndex 开始到末尾的所有行 const cleaned = lines.slice(0, endIndex).join('\n'); return cleaned; } return text; } // ---------- 核心功能 ---------- function init() { // ===== 只匹配本地 .md 文件 ===== if (!window.location.pathname.match(/\.md$/i)) { return; // 非 .md 文件静默退出 } document.body.appendChild(panel); document.body.appendChild(dropOverlay); initPanelPosition(); panel.addEventListener('mousedown', onPanelMouseDown); document.addEventListener('mousemove', onDocumentMouseMove); document.addEventListener('mouseup', onDocumentMouseUp); panel.addEventListener('click', function(e) { const btn = e.target.closest('.md-btn-control'); if (!btn) return; const action = btn.dataset.action; if (!action) return; handleStyleAction(action); }); const autoContent = getAutoContent(); if (autoContent) { currentSource = autoContent; currentFileName = getDocumentTitle(); buildSplitView(); } else { // 无内容时直接创建空白分屏,不显示拖放覆盖层 currentSource = ''; currentFileName = 'untitled'; buildSplitView(); } hideDropOverlay(); // 确保覆盖层始终隐藏 document.addEventListener('dragover', handleDragOver, false); document.addEventListener('drop', handleDrop, false); document.addEventListener('dragenter', handleDragEnter, false); document.addEventListener('dragleave', handleDragLeave, false); btnSave.addEventListener('click', async () => { if (!currentSource) { alert('没有内容可保存!'); return; } // 保存前:从 textarea 获取最新内容并清洗 if (textarea) { let content = textarea.value; content = cleanTrailingGarbage(content); // 清洗尾部垃圾 // 更新 textarea 和 currentSource 为清洗后的内容 textarea.value = content; currentSource = content; updatePreview(); // 刷新预览 } await saveMarkdownFile(); }); window.addEventListener('beforeunload', (e) => { if (textarea && textarea.value !== currentSource) { const msg = '您有未保存的编辑内容,确定离开吗?'; e.returnValue = msg; return msg; } }); window.addEventListener('resize', () => { const rect = panel.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; let needUpdate = false; let newLeft = rect.left; let newTop = rect.top; if (rect.left > maxX) { newLeft = Math.max(0, maxX); needUpdate = true; } if (rect.top > maxY) { newTop = Math.max(0, maxY); needUpdate = true; } if (needUpdate) { panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; savePanelPosition(); } }); } function getAutoContent() { const url = window.location.href; const isLocalFile = window.location.protocol === 'file:'; const isMarkdownFile = url.endsWith('.md') || url.endsWith('.markdown') || url.endsWith('.txt'); const isPlainText = document.body ? (document.body.childElementCount === 1 && document.body.firstElementChild && document.body.firstElementChild.tagName === 'PRE') : false; if (isLocalFile || isMarkdownFile || isPlainText) { let content = document.body.innerText || ''; content = cleanTrailingGarbage(content); return content; } return ''; } function getDocumentTitle() { const pathname = decodeURIComponent(window.location.pathname || 'document'); const filename = pathname.split('/').pop() || 'document'; return filename.replace(/\.(md|markdown|txt)$/i, '') || 'document'; } // ---------- 构建分屏视图 ---------- function buildSplitView() { document.body.innerHTML = ''; document.body.style.margin = '0'; document.body.style.padding = '0'; document.body.style.background = '#1e1f29'; container = document.createElement('div'); container.className = 'md-split-container'; previewPane = document.createElement('div'); previewPane.className = 'md-preview-pane'; const previewInner = document.createElement('div'); previewInner.className = 'md-rendered'; previewPane.appendChild(previewInner); splitter = document.createElement('div'); splitter.className = 'md-splitter'; const editorPane = document.createElement('div'); editorPane.className = 'md-editor-pane'; textarea = document.createElement('textarea'); textarea.value = currentSource; textarea.spellcheck = false; editorPane.appendChild(textarea); container.appendChild(previewPane); container.appendChild(splitter); container.appendChild(editorPane); document.body.appendChild(container); document.body.appendChild(panel); textarea.addEventListener('input', function() { currentSource = this.value; updatePreview(); }); updatePreview(); let isResizing = false; let startX = 0; let startLeftWidth = 0; splitter.addEventListener('mousedown', function(e) { isResizing = true; startX = e.clientX; startLeftWidth = previewPane.getBoundingClientRect().width; splitter.classList.add('dragging'); document.body.style.cursor = 'col-resize'; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!isResizing) return; const deltaX = e.clientX - startX; const containerWidth = container.getBoundingClientRect().width; const splitterWidth = splitter.getBoundingClientRect().width; let newLeftWidth = Math.min( Math.max(startLeftWidth + deltaX, containerWidth * 0.1), containerWidth * 0.9 - splitterWidth ); const rightWidth = containerWidth - newLeftWidth - splitterWidth; previewPane.style.flex = `0 0 ${newLeftWidth}px`; editorPane.style.flex = `0 0 ${rightWidth}px`; }); document.addEventListener('mouseup', function() { if (isResizing) { isResizing = false; splitter.classList.remove('dragging'); document.body.style.cursor = ''; } }); hideDropOverlay(); } function updatePreview() { if (!previewPane) return; const renderDiv = previewPane.querySelector('.md-rendered'); if (renderDiv) { const html = MarkdownParser.parse(currentSource); renderDiv.innerHTML = html; } } // ---------- 拖放处理 ---------- function handleDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; showDropOverlay(); } function handleDragEnter(e) { e.preventDefault(); showDropOverlay(); } function handleDragLeave(e) { e.preventDefault(); if (e.relatedTarget === null) { hideDropOverlay(); } } function handleDrop(e) { e.preventDefault(); hideDropOverlay(); const files = e.dataTransfer.files; if (files.length === 0) return; const file = files[0]; const ext = file.name.split('.').pop().toLowerCase(); if (!['md', 'markdown', 'txt'].includes(ext)) { alert('请拖入 .md .markdown 或 .txt 文件!'); return; } const reader = new FileReader(); reader.onload = function(event) { let content = event.target.result; content = cleanTrailingGarbage(content); currentSource = content; currentFileName = file.name.replace(/\.[^.]+$/, '') || 'document'; if (textarea) { textarea.value = currentSource; updatePreview(); } else { buildSplitView(); } hideDropOverlay(); }; reader.onerror = function() { alert('读取文件失败,请重试。'); }; reader.readAsText(file, 'UTF-8'); } function showDropOverlay() { dropOverlay.style.opacity = '1'; dropOverlay.style.pointerEvents = 'auto'; } function hideDropOverlay() { dropOverlay.style.opacity = '0'; dropOverlay.style.pointerEvents = 'none'; } // ---------- 保存为 .md ---------- async function saveMarkdownFile() { // 再次清洗,确保万无一失 if (textarea) { let content = textarea.value; content = cleanTrailingGarbage(content); textarea.value = content; currentSource = content; updatePreview(); } if (!currentSource) return; if ('showSaveFilePicker' in window) { try { const handle = await window.showSaveFilePicker({ suggestedName: currentFileName + '.md', types: [{ description: 'Markdown 文件', accept: { 'text/markdown': ['.md'] } }] }); const writable = await handle.createWritable(); await writable.write('\ufeff' + currentSource); await writable.close(); alert('✅ 文件已保存!'); return; } catch (err) { if (err.name !== 'AbortError') { console.warn('保存失败,回退到下载', err); } else { return; } } } const blob = new Blob(['\ufeff' + currentSource], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = currentFileName + '.md'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // ========== 样式工具栏核心逻辑(全部支持切换) ========== // 辅助:切换行内标记(加粗、倾斜、代码) function toggleInlineMark(text, mark) { if (!text) return mark + mark; if (text.startsWith(mark) && text.endsWith(mark)) { return text.slice(mark.length, -mark.length); } return mark + text + mark; } // 辅助:对每行应用或移除前缀(引用、列表、有序) function toggleLinePrefix(text, prefix, regex) { if (!text) return prefix + ' '; const lines = text.split('\n'); const newLines = lines.map(line => { const trimmed = line; if (regex) { // 有序列表:检测是否以数字+.开头 if (regex.test(trimmed)) { return trimmed.replace(regex, ''); } else { return prefix + trimmed; } } else { if (trimmed.startsWith(prefix)) { return trimmed.slice(prefix.length); } else { return prefix + trimmed; } } }); return newLines.join('\n'); } // 辅助:检测并移除包裹(代码块、链接、图片、颜色) function toggleWrapped(text, open, close, stripOpen, stripClose) { if (!text) return open + close; // 空选区插入占位 // 检查是否被 open 和 close 包裹 if (text.startsWith(open) && text.endsWith(close)) { let inner = text.slice(open.length, -close.length); // 对于链接/图片,可能还有额外处理,但通用剥离 return inner; } else { return open + text + close; } } // 专门处理颜色:检测是否被 包裹 function toggleColor(text) { if (!text) return '彩色文本'; // 检测是否被 ... 包裹 const match = text.match(/^(.*)<\/span>$/s); if (match) { return match[1]; // 剥离标签 } else { // 不包裹,弹出颜色选择器由外部处理,这里返回null表示需要弹出选择器 return null; } } function handleStyleAction(action) { if (!textarea) return; const ta = textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const selected = ta.value.substring(start, end); let replacement = ''; let cursorOffset = 0; // 处理标题(已有切换) const headingMap = { 'h1': 1, 'h2': 2, 'h3': 3 }; if (action in headingMap) { const level = headingMap[action]; if (!selected) { // 空选区:作用于当前行 const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; let lineEnd = ta.value.indexOf('\n', start); if (lineEnd === -1) lineEnd = ta.value.length; const lineText = ta.value.substring(lineStart, lineEnd); const newLine = toggleHeadingLine(lineText, level); ta.value = ta.value.substring(0, lineStart) + newLine + ta.value.substring(lineEnd); ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; } else { // 有选区:处理每行 const lines = selected.split('\n'); const newLines = lines.map(line => toggleHeadingLine(line, level)); replacement = newLines.join('\n'); ta.value = ta.value.substring(0, start) + replacement + ta.value.substring(end); ta.selectionStart = start; ta.selectionEnd = start + replacement.length; } ta.focus(); const event = new Event('input', { bubbles: true }); ta.dispatchEvent(event); currentSource = ta.value; updatePreview(); return; } // 辅助:应用切换并更新 function applyReplacement(repl, cursorPos) { ta.value = ta.value.substring(0, start) + repl + ta.value.substring(end); if (cursorPos !== undefined) { ta.selectionStart = ta.selectionEnd = cursorPos; } else { ta.selectionStart = start; ta.selectionEnd = start + repl.length; } ta.focus(); const event = new Event('input', { bubbles: true }); ta.dispatchEvent(event); currentSource = ta.value; updatePreview(); } switch (action) { case 'bold': replacement = toggleInlineMark(selected, '**'); if (!selected) cursorOffset = 2; applyReplacement(replacement, start + cursorOffset); break; case 'italic': replacement = toggleInlineMark(selected, '*'); if (!selected) cursorOffset = 1; applyReplacement(replacement, start + cursorOffset); break; case 'code': replacement = toggleInlineMark(selected, '`'); if (!selected) cursorOffset = 1; applyReplacement(replacement, start + cursorOffset); break; case 'codeblock': { if (!selected) { replacement = '```\n\n```'; applyReplacement(replacement, start + 4); } else { // 检测是否被 ``` 包裹 if (selected.startsWith('```\n') && selected.endsWith('\n```')) { // 移除最外层的 ``` let inner = selected.slice(4, -4); // 去掉 ```\n 和 \n``` // 如果内部开头有换行,可保留 applyReplacement(inner, start); } else { replacement = '```\n' + selected + '\n```'; applyReplacement(replacement, start + replacement.length); } } break; } case 'quote': { if (!selected) { // 空选区:作用于当前行 const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; let lineEnd = ta.value.indexOf('\n', start); if (lineEnd === -1) lineEnd = ta.value.length; const lineText = ta.value.substring(lineStart, lineEnd); // 检测是否以 '> ' 开头 if (lineText.startsWith('> ')) { replacement = lineText.slice(2); } else { replacement = '> ' + lineText; } ta.value = ta.value.substring(0, lineStart) + replacement + ta.value.substring(lineEnd); ta.selectionStart = ta.selectionEnd = lineStart + replacement.length; } else { // 有选区:处理每行 const lines = selected.split('\n'); const newLines = lines.map(line => { if (line.startsWith('> ')) return line.slice(2); else return '> ' + line; }); replacement = newLines.join('\n'); ta.value = ta.value.substring(0, start) + replacement + ta.value.substring(end); ta.selectionStart = start; ta.selectionEnd = start + replacement.length; } ta.focus(); const ev = new Event('input', { bubbles: true }); ta.dispatchEvent(ev); currentSource = ta.value; updatePreview(); break; } case 'ul': { if (!selected) { const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; let lineEnd = ta.value.indexOf('\n', start); if (lineEnd === -1) lineEnd = ta.value.length; const lineText = ta.value.substring(lineStart, lineEnd); if (lineText.startsWith('- ')) { replacement = lineText.slice(2); } else { replacement = '- ' + lineText; } ta.value = ta.value.substring(0, lineStart) + replacement + ta.value.substring(lineEnd); ta.selectionStart = ta.selectionEnd = lineStart + replacement.length; } else { const lines = selected.split('\n'); const newLines = lines.map(line => { if (line.startsWith('- ')) return line.slice(2); else return '- ' + line; }); replacement = newLines.join('\n'); ta.value = ta.value.substring(0, start) + replacement + ta.value.substring(end); ta.selectionStart = start; ta.selectionEnd = start + replacement.length; } ta.focus(); const ev = new Event('input', { bubbles: true }); ta.dispatchEvent(ev); currentSource = ta.value; updatePreview(); break; } case 'ol': { const olRegex = /^\d+\.\s/; if (!selected) { const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; let lineEnd = ta.value.indexOf('\n', start); if (lineEnd === -1) lineEnd = ta.value.length; const lineText = ta.value.substring(lineStart, lineEnd); if (olRegex.test(lineText)) { replacement = lineText.replace(olRegex, ''); } else { replacement = '1. ' + lineText; } ta.value = ta.value.substring(0, lineStart) + replacement + ta.value.substring(lineEnd); ta.selectionStart = ta.selectionEnd = lineStart + replacement.length; } else { const lines = selected.split('\n'); const newLines = lines.map(line => { if (olRegex.test(line)) { return line.replace(olRegex, ''); } else { return '1. ' + line; } }); replacement = newLines.join('\n'); ta.value = ta.value.substring(0, start) + replacement + ta.value.substring(end); ta.selectionStart = start; ta.selectionEnd = start + replacement.length; } ta.focus(); const ev = new Event('input', { bubbles: true }); ta.dispatchEvent(ev); currentSource = ta.value; updatePreview(); break; } case 'link': { if (!selected) { replacement = '[](url)'; applyReplacement(replacement, start + 1); } else { // 检测是否匹配 [text](url) const linkMatch = selected.match(/^\[([^\]]+)\]\(([^)]+)\)$/); if (linkMatch) { // 提取文本 replacement = linkMatch[1]; applyReplacement(replacement, start); } else { replacement = '[' + selected + '](url)'; applyReplacement(replacement, start + replacement.length); } } break; } case 'image': { if (!selected) { replacement = '![](url)'; applyReplacement(replacement, start + 2); } else { const imgMatch = selected.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (imgMatch) { replacement = imgMatch[1]; applyReplacement(replacement, start); } else { replacement = '![' + selected + '](url)'; applyReplacement(replacement, start + replacement.length); } } break; } case 'color': { if (!selected) { // 空选区:插入默认彩色文本,并弹出选择器?这里简单插入占位 replacement = '彩色文本'; applyReplacement(replacement, start + replacement.length); break; } // 检测是否已被颜色标签包裹 const colorMatch = selected.match(/^(.*)<\/span>$/s); if (colorMatch) { // 移除颜色标签 replacement = colorMatch[1]; applyReplacement(replacement, start); } else { // 弹出颜色选择器 const oldInput = document.getElementById('md-color-input'); if (oldInput) oldInput.remove(); const input = document.createElement('input'); input.type = 'color'; input.id = 'md-color-input'; input.style.position = 'fixed'; input.style.left = '-9999px'; input.style.top = '-9999px'; document.body.appendChild(input); input.addEventListener('change', function(e) { const color = this.value; const repl = `${selected}`; ta.value = ta.value.substring(0, start) + repl + ta.value.substring(end); ta.selectionStart = start; ta.selectionEnd = start + repl.length; ta.focus(); const ev = new Event('input', { bubbles: true }); ta.dispatchEvent(ev); currentSource = ta.value; updatePreview(); this.remove(); }); input.addEventListener('blur', function() { if (this.parentNode) this.remove(); }); input.click(); return; // 异步处理,不继续 } break; } default: return; } } // 切换标题(保持原有逻辑) function toggleHeadingLine(line, level) { const leadingSpaces = line.match(/^(\s*)/)[0]; const trimmed = line.trimLeft(); const match = trimmed.match(/^(#+)\s/); if (match) { const currentLevel = match[1].length; const content = trimmed.substring(match[0].length); if (currentLevel === level) { return leadingSpaces + content; } else { return leadingSpaces + '#'.repeat(level) + ' ' + content; } } else { return leadingSpaces + '#'.repeat(level) + ' ' + trimmed; } } // ---------- 内置 Markdown 解析引擎 ---------- const MarkdownParser = { parse(md) { let html = md; html = this.parseCodeBlocks(html); // 允许内联 HTML(颜色样式生效) // html = this.escapeHtml(html); html = html.replace(/^######\s+(.+)$/gm, '
$1
'); html = html.replace(/^#####\s+(.+)$/gm, '
$1
'); html = html.replace(/^####\s+(.+)$/gm, '

$1

'); html = html.replace(/^###\s+(.+)$/gm, '

$1

'); html = html.replace(/^##\s+(.+)$/gm, '

$1

'); html = html.replace(/^#\s+(.+)$/gm, '

$1

'); html = html.replace(/^(-{3,}|_{3,}|\*{3,})$/gm, '
'); html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/\*(.+?)\*/g, '$1'); html = html.replace(/~~(.+?)~~/g, '$1'); html = html.replace(/`([^`]+)`/g, '$1'); html = this.parseBlockquotes(html); html = this.parseLists(html); html = this.parseTables(html); html = this.restoreCodeBlocks(html); html = this.cleanupHtmlArtifacts(html); html = this.wrapParagraphs(html); return html; }, escapeHtml(text) { return text.replace(/&/g, '&').replace(//g, '>'); }, codeBlocks: [], parseCodeBlocks(text) { this.codeBlocks = []; return text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => { const index = this.codeBlocks.push({ lang, code: code.trim() }) - 1; return `CODEBLOCKPLACEHOLDER${index}BLOCK`; }); }, restoreCodeBlocks(text) { return text.replace(/CODEBLOCKPLACEHOLDER(\d+)BLOCK/g, (match, index) => { const block = this.codeBlocks[index]; const escapedCode = block.code.replace(/&/g, '&').replace(//g, '>'); return `
${this.highlight(escapedCode, block.lang)}
`; }); }, highlight(code, lang) { const keywords = { js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'import', 'export'], python: ['def', 'class', 'return', 'if', 'else', 'import', 'from', 'as', 'True', 'False'], default: [] }; const kwList = keywords[lang] || keywords.default; const placeholderStore = []; const stash = (text) => `HIGHLIGHTPLACEHOLDER${placeholderStore.push(text) - 1}TOKEN`; let res = code; res = res.replace(/(["'`])(?:(?!\1)[^\\]|\\.)*?\1/g, match => stash(`${match}`)); res = res.replace(/(\/\/.*$|#.*$)/gm, match => stash(`${match}`)); res = res.replace(/\b(\d+\.?\d*)\b/g, '$1'); kwList.forEach(k => { res = res.replace(new RegExp(`\\b(${k})\\b`, 'g'), '$1'); }); res = res.replace(/HIGHLIGHTPLACEHOLDER(\d+)TOKEN/g, (match, index) => placeholderStore[Number(index)] || ''); return res; }, parseBlockquotes(text) { const lines = text.split('\n'); let html = []; let inQuote = false; lines.forEach(line => { if (line.startsWith('> ') || line.startsWith('> ')) { if (!inQuote) { html.push('
'); inQuote = true; } html.push(line.replace(/^(?:>|>)\s*/, '')); } else { if (inQuote) { html.push('
'); inQuote = false; } html.push(line); } }); if (inQuote) html.push(''); return html.join('\n'); }, parseLists(text) { const lines = text.split('\n'); let html = []; let listType = null; lines.forEach(line => { const ulMatch = line.match(/^[\s]*[-*+]\s+(.+)/); const olMatch = line.match(/^[\s]*\d+\.\s+(.+)/); if (ulMatch) { if (listType !== 'ul') { if (listType) html.push(``); html.push('
    '); listType = 'ul'; } html.push(`
  • ${ulMatch[1]}
  • `); } else if (olMatch) { if (listType !== 'ol') { if (listType) html.push(``); html.push('
      '); listType = 'ol'; } html.push(`
    1. ${olMatch[1]}
    2. `); } else { if (listType) { html.push(``); listType = null; } html.push(line); } }); if (listType) html.push(``); return html.join('\n'); }, parseTables(text) { return text.replace(/^(\|.+\|)\s*\n\|[-:\s|]+\|\s*\n((?:\|.+\|\s*\n?)+)/gm, (m, h, b) => { const headerCells = h.split('|').slice(1, -1).map(s => `${s.trim()}`).join(''); const bodyRows = b.trim().split('\n').map(r => { const rowCells = r.split('|').slice(1, -1).map(c => `${c.trim()}`).join(''); return `${rowCells}`; }).join(''); return `${headerCells}${bodyRows}
      `; }); }, wrapParagraphs(text) { const blocks = text.split(/\n\n+/); return blocks.map(block => { block = block.trim(); if (!block) return ''; if (/^<(h[1-6]|ul|ol|pre|blockquote|table|hr|div|p)\b/i.test(block)) { return block; } const inner = block.replace(/\n/g, '
      '); return `

      ${inner}

      `; }).join('\n'); }, cleanupHtmlArtifacts(html) { return html .replace(/<(\/)?span class="(md-hl-(?:num|str|kw|cmt))">/g, '<$1span class="$2">') .replace(/<(\/)?(strong|em|del|code|blockquote|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|p|a|img|hr|div)([^&]*)>/g, '<$1$2$3>') .replace(/
      /g, '
      ') .replace(/<\/span>/g, '') .replace(/<\/a>/g, '') .replace(/<\/code>/g, '') .replace(/<\/strong>/g, '') .replace(/<\/em>/g, '') .replace(/<\/del>/g, '') .replace(/"/g, '"'); } }; // ---------- 启动 ---------- if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();