// ==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 = '';
applyReplacement(replacement, start + 2);
} else {
const imgMatch = selected.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imgMatch) {
replacement = imgMatch[1];
applyReplacement(replacement, start);
} else {
replacement = '';
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, '
');
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|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();
}
})();