// ==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, '
');
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(`${listType}>`); html.push(''); listType = 'ul'; }
html.push(`- ${ulMatch[1]}
`);
} else if (olMatch) {
if (listType !== 'ol') { if (listType) html.push(`${listType}>`); html.push(''); listType = 'ol'; }
html.push(`- ${olMatch[1]}
`);
} else {
if (listType) { html.push(`${listType}>`); listType = null; }
html.push(line);
}
});
if (listType) html.push(`${listType}>`);
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)\b/i.test(block)) {
return block;
}
return `${block.replace(/\n/g, '
')}`;
}).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();
}
})();