// ==UserScript== // @name Markdown 阅读器 & 编辑器 (分屏实时预览) // @namespace http://tampermonkey.net/ // @version 4.1 // @description 拖入 .md 文件,左侧预览右侧编辑,中间可拖动调整宽度 // @author MRBANK (modified) // @match file:///* // @grant GM_addStyle // @run-at document-start // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS41Ii8+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'; 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; top: 12px; right: 12px; z-index: 2147483647; display: flex; background: #2b2b2b; padding: 4px; 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; } .md-btn-control { background: transparent; color: #999; border: none; padding: 6px 16px; font-size: 14px; font-weight: 500; cursor: pointer; border-radius: 6px; transition: all 0.2s ease-in-out; line-height: 1.5; } .md-btn-control:hover { color: #fff; } .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; } .md-preview-pane .md-rendered { max-width: 900px; margin: 0 auto; line-height: 1.6; } .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; font-weight: 600; 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: 600; } .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::-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; } `); // ---------- 核心功能 ---------- function init() { document.body.appendChild(panel); document.body.appendChild(dropOverlay); const autoContent = getAutoContent(); if (autoContent) { currentSource = autoContent; currentFileName = getDocumentTitle(); buildSplitView(); hideDropOverlay(); } else { showDropOverlay(); document.body.innerHTML = ''; document.body.appendChild(panel); document.body.appendChild(dropOverlay); dropOverlay.style.pointerEvents = 'auto'; dropOverlay.style.opacity = '1'; } document.addEventListener('dragover', handleDragOver, false); document.addEventListener('drop', handleDrop, false); document.addEventListener('dragenter', handleDragEnter, false); document.addEventListener('dragleave', handleDragLeave, false); btnSave.addEventListener('click', () => { if (!currentSource) { alert('没有内容可保存!'); return; } if (textarea) { currentSource = textarea.value; } saveMarkdownFile(); }); window.addEventListener('beforeunload', (e) => { if (textarea && textarea.value !== currentSource) { const msg = '您有未保存的编辑内容,确定离开吗?'; e.returnValue = msg; return msg; } }); } 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) { return document.body.innerText || ''; } 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 isDragging = false; let startX = 0; let startLeftWidth = 0; splitter.addEventListener('mousedown', function(e) { isDragging = 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 (!isDragging) return; const deltaX = e.clientX - startX; const containerWidth = container.getBoundingClientRect().width; const splitterWidth = splitter.getBoundingClientRect().width; // 计算新的左侧宽度(像素),限制最小/最大(各占10%~90%) let newLeftWidth = Math.min( Math.max(startLeftWidth + deltaX, containerWidth * 0.1), containerWidth * 0.9 - splitterWidth ); const rightWidth = containerWidth - newLeftWidth - splitterWidth; // 使用 flex-basis 或直接设置宽度 previewPane.style.flex = `0 0 ${newLeftWidth}px`; editorPane.style.flex = `0 0 ${rightWidth}px`; }); document.addEventListener('mouseup', function() { if (isDragging) { isDragging = 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) { currentSource = event.target.result; 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 ---------- function saveMarkdownFile() { if (textarea) { currentSource = textarea.value; } if (!currentSource) 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); } // ---------- 内置 Markdown 解析引擎(保持不变) ---------- const MarkdownParser = { parse(md) { let html = md; html = this.parseCodeBlocks(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('