// MarkLite Renderer Process
(function () {
'use strict';
// ===== DOM Elements =====
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const lineNumbers = document.getElementById('line-numbers');
const welcomeScreen = document.getElementById('welcome-screen');
const dropOverlay = document.getElementById('drop-overlay');
const modifiedBanner = document.getElementById('modified-banner');
const statusText = document.getElementById('status-text');
const statusCursor = document.getElementById('status-cursor');
const statusSize = document.getElementById('status-size');
const statusSizeDivider = document.querySelector('.status-size-divider');
const resizer = document.getElementById('resizer');
const editorPanel = document.getElementById('editor-panel');
const previewPanel = document.getElementById('preview-panel');
const mainContent = document.getElementById('main-content');
const contentWrapper = document.getElementById('content-wrapper');
const tabList = document.getElementById('tab-list');
// Sidebar
const sidebar = document.getElementById('sidebar');
const sidebarTree = document.getElementById('sidebar-tree');
const btnOpenFolder = document.getElementById('btn-open-folder');
// Search
const searchBar = document.getElementById('search-bar');
const searchInput = document.getElementById('search-input');
const replaceInput = document.getElementById('replace-input');
const searchCount = document.getElementById('search-count');
const replaceRow = document.getElementById('replace-row');
const btnCase = document.getElementById('btn-case');
const btnRegex = document.getElementById('btn-regex');
const btnNext = document.getElementById('btn-next');
const btnPrev = document.getElementById('btn-prev');
const btnSearchClose = document.getElementById('btn-search-close');
const btnReplace = document.getElementById('btn-replace');
const btnReplaceAll = document.getElementById('btn-replace-all');
// Buttons
const btnOpen = document.getElementById('btn-open');
const btnSave = document.getElementById('btn-save');
const btnSplit = document.getElementById('btn-split');
const btnEditor = document.getElementById('btn-editor');
const btnPreview = document.getElementById('btn-preview');
const btnWelcomeOpen = document.getElementById('btn-welcome-open');
const btnWelcomeNew = document.getElementById('btn-welcome-new');
const btnDark = document.getElementById('btn-dark');
const btnReload = document.getElementById('btn-reload');
const btnDismiss = document.getElementById('btn-dismiss');
const btnNewTab = document.getElementById('btn-new-tab');
// ===== Tab State =====
let tabs = []; // Array of tab objects
let activeTabId = null;
let tabIdCounter = 0;
// ===== Other State =====
let viewMode = 'split';
let updateTimer = null;
let lineCountCache = 0;
let currentRootPath = null;
// Search state
let searchMatches = [];
let searchIndex = -1;
let searchCaseSensitive = false;
let searchUseRegex = false;
// ===== Tab Object =====
function createTab(filePath, content) {
const id = ++tabIdCounter;
return {
id,
filePath: filePath || null,
content: content || '',
isModified: false,
scrollTop: 0,
scrollLeft: 0,
selectionStart: 0,
selectionEnd: 0,
previewScrollTop: 0
};
}
// ===== localStorage Helpers =====
function loadSettings() {
try {
return JSON.parse(localStorage.getItem('marklite-settings') || '{}');
} catch { return {}; }
}
function saveSetting(key, value) {
try {
const s = loadSettings();
s[key] = value;
localStorage.setItem('marklite-settings', JSON.stringify(s));
} catch { /* ignore */ }
}
// ===== Dark Mode =====
function initDarkMode() {
const settings = loadSettings();
const iconDark = document.getElementById('icon-dark');
const iconLight = document.getElementById('icon-light');
function apply(isDark) {
document.documentElement.classList.toggle('dark', isDark);
iconDark.style.display = isDark ? 'none' : '';
iconLight.style.display = isDark ? '' : 'none';
}
// Respect system preference if no saved setting
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
apply(settings.darkMode !== undefined ? !!settings.darkMode : prefersDark);
btnDark.addEventListener('click', () => {
const isDark = !document.documentElement.classList.contains('dark');
apply(isDark);
saveSetting('darkMode', isDark);
});
}
// ===== Initialize marked.js =====
function initMarked() {
if (typeof marked !== 'undefined') {
const renderer = new marked.Renderer();
// 兼容 marked v4(positional args)和 v9+(token object)
renderer.code = function (codeOrToken, legacyLang) {
const text = (typeof codeOrToken === 'object' && codeOrToken !== null)
? codeOrToken.text
: codeOrToken;
const lang = (typeof codeOrToken === 'object' && codeOrToken !== null)
? codeOrToken.lang
: legacyLang;
let highlighted = text;
if (typeof hljs !== 'undefined') {
if (lang && hljs.getLanguage(lang)) {
try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* fallback */ }
} else {
try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* fallback */ }
}
}
const langClass = lang ? ` language-${lang}` : '';
return `
${highlighted}
`;
};
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
marked.use({ renderer });
}
}
// ===== HTML Sanitizer =====
// 移除事件处理器、危险元素和 javascript: 协议,防止 XSS
const DANGEROUS_TAGS = new Set([
'script', 'iframe', 'object', 'embed', 'applet', 'form',
'base', 'meta', 'link', 'style'
]);
const DANGEROUS_ATTRS = new Set([
'onabort', 'onanimationend', 'onanimationiteration', 'onanimationstart',
'onauxclick', 'onbeforecopy', 'onbeforecut', 'onbeforepaste', 'onblur',
'oncancel', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick',
'onclose', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut',
'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied',
'onended', 'onerror', 'onfocus', 'onfocusin', 'onfocusout',
'onfullscreenchange', 'onfullscreenerror', 'ongotpointercapture',
'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup',
'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart',
'onlostpointercapture', 'onmousedown', 'onmouseenter', 'onmouseleave',
'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel',
'onpaste', 'onpause', 'onplay', 'onplaying', 'onpointercancel',
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
'onpointerout', 'onpointerover', 'onpointerup', 'onprogress',
'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch',
'onseeked', 'onseeking', 'onselect', 'onselectionchange',
'onselectstart', 'onshow', 'onstalled', 'onsubmit', 'onsuspend',
'ontimeupdate', 'ontoggle', 'ontouchcancel', 'ontouchend',
'ontouchmove', 'ontouchstart', 'ontransitionend', 'onvolumechange',
'onwaiting', 'onwheel', 'onanimationend', 'oncontentvisibilityautostatechange',
'onscrollend', 'onscrollsnapchange', 'onscrollsnapchanging'
]);
function sanitizeHTML(html) {
// 用 DOMParser 解析(不会执行脚本)
const doc = new DOMParser().parseFromString(html, 'text/html');
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT);
const toRemove = [];
while (walker.nextNode()) {
const el = walker.currentNode;
const tag = el.tagName.toLowerCase();
// 移除危险元素
if (DANGEROUS_TAGS.has(tag)) {
toRemove.push(el);
continue;
}
// 移除危险属性(on* 事件处理器 + javascript: 协议)
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (DANGEROUS_ATTRS.has(name) || name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (
(name === 'href' || name === 'action' || name === 'formaction' || name === 'xlink:href') &&
/^\s*javascript:/i.test(attr.value)
) {
el.removeAttribute(attr.name);
} else if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(attr.value)) {
el.removeAttribute(attr.name);
}
}
}
// 批量移除危险元素
for (const el of toRemove) {
el.parentNode.removeChild(el);
}
return doc.body.innerHTML;
}
// ===== Markdown Rendering =====
function renderMarkdown(text) {
if (typeof marked === 'undefined') {
preview.textContent = '错误: Markdown 解析库未加载';
preview.style.color = 'red';
return;
}
try {
const raw = marked.parse(text);
preview.innerHTML = sanitizeHTML(raw);
preview.style.color = '';
} catch (e) {
preview.textContent = '渲染错误: ' + e.message;
preview.style.color = 'red';
}
}
// Intercept link clicks in preview — open in system browser
function initPreviewLinks() {
preview.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (!link) return;
e.preventDefault();
const href = link.getAttribute('href');
if (!href) return;
// Anchor links: scroll within preview
if (href.startsWith('#')) {
const target = preview.querySelector(href);
if (target) target.scrollIntoView({ behavior: 'smooth' });
return;
}
// Only allow http/https protocols
try {
const url = new URL(href);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
} catch {
// Relative URLs fail new URL(), but we already handled anchors above
return;
}
// External links: open in system browser
if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openExternal) {
window.electronAPI.openExternal(href);
} else {
window.open(href, '_blank', 'noopener');
}
});
}
// ===== Line Numbers =====
// 快速计算换行符数量,避免 split 产生大数组
function countNewlines(text) {
let count = 1;
for (let i = 0, len = text.length; i < len; i++) {
if (text.charCodeAt(i) === 10) count++; // '\n' = 0x0A
}
return count;
}
function updateLineNumbers() {
const count = countNewlines(editor.value);
if (count === lineCountCache) return;
lineCountCache = count;
// 大文件(>2000 行)时用虚拟化:只渲染可视区域的行号
const LARGE_FILE_THRESHOLD = 2000;
if (count > LARGE_FILE_THRESHOLD) {
renderVirtualLineNumbers(count);
} else {
// 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
const arr = new Array(count);
for (let i = 0; i < count; i++) {
arr[i] = '' + (i + 1) + '
';
}
lineNumbers.innerHTML = arr.join('');
}
}
// 虚拟化行号:只渲染当前可视区域附近的行号
function renderVirtualLineNumbers(totalLines) {
const lineHeight = 20.8; // 与 CSS line-height 一致
const scrollTop = editor.scrollTop;
const viewportHeight = editor.clientHeight;
const buffer = 20; // 上下缓冲行数
const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer);
const endLine = Math.min(totalLines, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer);
const visibleCount = endLine - startLine;
// 顶部占位
const topPadding = startLine * lineHeight;
// 底部占位
const bottomPadding = (totalLines - endLine) * lineHeight;
const arr = new Array(visibleCount + 2);
arr[0] = '';
for (let i = 0; i < visibleCount; i++) {
arr[i + 1] = '' + (startLine + i + 1) + '
';
}
arr[visibleCount + 1] = '';
lineNumbers.innerHTML = arr.join('');
}
// ===== Cursor Position =====
function updateCursorPosition() {
const text = editor.value;
const pos = editor.selectionStart;
const textBefore = text.substring(0, pos);
const lines = textBefore.split('\n');
statusCursor.textContent = `行 ${lines.length}, 列 ${lines[lines.length - 1].length + 1}`;
}
// ===== File Size =====
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function updateFileSize() {
const bytes = new Blob([editor.value]).size;
statusSize.textContent = formatBytes(bytes);
statusSizeDivider.classList.add('visible');
}
// ===== Scroll Sync =====
let previewPositions = null;
let lineToPreviewMap = null; // 行号 → 预览像素位置的映射
function cachePreviewPositions() {
previewPositions = [];
for (let i = 0; i < preview.children.length; i++) {
previewPositions.push(preview.children[i].offsetTop);
}
// 构建行号→预览位置的映射表
buildLineToPreviewMap();
}
// 通过分析原始 Markdown 文本,建立编辑器行号到预览 DOM 位置的映射
function buildLineToPreviewMap() {
if (!previewPositions || previewPositions.length === 0) {
lineToPreviewMap = null;
return;
}
const text = editor.value;
const lines = text.split('\n');
const totalLines = lines.length;
// 找出每个"块级元素"在编辑器中的起始行
// 块级元素:标题、段落、代码块、列表、引用、表格、水平线
const blockStartLines = [];
let inCodeBlock = false;
for (let i = 0; i < totalLines; i++) {
const line = lines[i];
const trimmed = line.trimStart();
// 围栏代码块边界
if (trimmed.startsWith('```')) {
if (!inCodeBlock) {
blockStartLines.push(i);
inCodeBlock = true;
} else {
inCodeBlock = false;
}
continue;
}
if (inCodeBlock) continue;
// 块级元素起始行
if (
trimmed.startsWith('#') || // 标题
trimmed.startsWith('- ') || // 无序列表
trimmed.startsWith('* ') || // 无序列表
trimmed.startsWith('+ ') || // 无序列表
/^\d+\.\s/.test(trimmed) || // 有序列表
trimmed.startsWith('> ') || // 引用
trimmed.startsWith('|') || // 表格
trimmed.startsWith('---') || // 水平线
trimmed.startsWith('***') || // 水平线
trimmed.startsWith('___') || // 水平线
(trimmed === '' && i > 0 && lines[i - 1].trim() === '') // 连续空行=段落分隔
) {
blockStartLines.push(i);
}
}
// 去重并排序
const uniqueStarts = [...new Set(blockStartLines)].sort((a, b) => a - b);
// 将编辑器行号映射到预览 DOM 位置
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
lineToPreviewMap = new Float32Array(totalLines);
if (uniqueStarts.length === 0) {
// 没有找到块边界,回退到线性映射
for (let i = 0; i < totalLines; i++) {
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1];
}
return;
}
// 将块起始行映射到预览 DOM 索引
const domCount = previewPositions.length;
for (let i = 0; i < uniqueStarts.length; i++) {
const lineIdx = uniqueStarts[i];
const domIdx = Math.min(Math.floor((i / uniqueStarts.length) * domCount), domCount - 1);
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
}
// 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStarts.includes(i)) continue;
// 找到前后最近的已映射行
let prevLine = -1, nextLine = -1;
for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { prevLine = j; break; }
}
for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { nextLine = j; break; }
}
if (prevLine === -1 && nextLine === -1) {
lineToPreviewMap[i] = 0;
} else if (prevLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[nextLine];
} else if (nextLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[prevLine];
} else {
// 线性插值
const ratio = (i - prevLine) / (nextLine - prevLine);
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine]);
}
}
}
function syncScroll() {
if (viewMode !== 'split') return;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const scrollTop = editor.scrollTop;
const topLine = Math.floor(scrollTop / lineHeight);
if (!previewPositions || previewPositions.length === 0) cachePreviewPositions();
if (!previewPositions || previewPositions.length === 0) return;
// 使用行号→预览位置映射表
if (lineToPreviewMap && topLine >= 0 && topLine < lineToPreviewMap.length) {
previewPanel.scrollTop = lineToPreviewMap[topLine];
} else {
// fallback: 线性比例映射
const totalLines = editor.value.split('\n').length;
const ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0;
const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1);
if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) {
previewPanel.scrollTop = previewPositions[targetIndex];
}
}
}
// ===== Schedule Update =====
function scheduleUpdate() {
if (updateTimer) clearTimeout(updateTimer);
updateTimer = setTimeout(() => {
renderMarkdown(editor.value);
updateLineNumbers();
updateFileSize();
cachePreviewPositions();
}, 150);
}
// ===== Helper =====
function getFileName(filePath) {
return filePath.split(/[/\\]/).pop();
}
// ===== Tab UI =====
function renderTabBar() {
tabList.innerHTML = '';
for (const tab of tabs) {
const el = document.createElement('div');
el.className = 'tab-item' + (tab.id === activeTabId ? ' active' : '') + (tab.isModified ? ' modified' : '');
el.dataset.tabId = tab.id;
const name = document.createElement('span');
name.className = 'tab-name';
name.textContent = tab.filePath ? getFileName(tab.filePath) : '未命名';
el.appendChild(name);
const close = document.createElement('button');
close.className = 'tab-close';
close.innerHTML = '';
close.addEventListener('click', (e) => {
e.stopPropagation();
closeTab(tab.id);
});
el.appendChild(close);
el.addEventListener('click', () => switchToTab(tab.id));
tabList.appendChild(el);
}
}
function updateTitle() {
const tab = getActiveTab();
if (tab) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名';
const mod = tab.isModified ? ' *' : '';
document.title = `MarkLite - ${name}${mod}`;
statusText.textContent = name;
} else {
document.title = 'MarkLite';
statusText.textContent = '就绪';
}
}
// ===== Sidebar Highlight =====
// Update sidebar active highlight without re-rendering the entire tree
function updateSidebarHighlight() {
const tab = getActiveTab();
const activePath = tab ? tab.filePath : null;
// No folder open: manage independent files section
if (!currentRootPath) {
sidebarTree.innerHTML = '';
const indepSection = renderIndependentFiles();
if (indepSection) sidebarTree.appendChild(indepSection);
return;
}
// Folder is open: re-render independent files section (files may have been added/removed)
const oldSection = sidebarTree.querySelector('.independent-files-section');
const newSection = renderIndependentFiles();
if (oldSection) oldSection.remove();
if (newSection) sidebarTree.insertBefore(newSection, sidebarTree.firstChild);
// Update folder tree items
const treeItems = sidebarTree.querySelectorAll('.tree-item:not(.independent-file-item)');
treeItems.forEach(item => {
const itemPath = item.dataset.path;
if (itemPath) {
item.classList.toggle('active', itemPath === activePath);
}
});
}
// ===== Tab Operations =====
function getActiveTab() {
return tabs.find(t => t.id === activeTabId) || null;
}
function getTabIndex(tabId) {
return tabs.findIndex(t => t.id === tabId);
}
// Save current editor state into the active tab
function saveCurrentTabState() {
const tab = getActiveTab();
if (!tab) return;
tab.content = editor.value;
tab.scrollTop = editor.scrollTop;
tab.scrollLeft = editor.scrollLeft;
tab.selectionStart = editor.selectionStart;
tab.selectionEnd = editor.selectionEnd;
tab.previewScrollTop = previewPanel.scrollTop;
}
// Load a tab's state into the editor
function loadTabState(tab) {
// 使用 execCommand('insertText') 替换内容,保留 undo/redo 历史
// setRangeText 会清空 undo 栈,所以此处必须用 execCommand
editor.focus();
editor.select();
const inserted = document.execCommand('insertText', false, tab.content);
if (!inserted) {
// execCommand 被禁用时的 fallback(会丢失 undo 历史)
editor.value = tab.content;
}
lineCountCache = 0;
updateLineNumbers();
renderMarkdown(tab.content);
updateFileSize();
cachePreviewPositions();
// Restore scroll/selection on next frame
requestAnimationFrame(() => {
editor.scrollTop = tab.scrollTop;
editor.scrollLeft = tab.scrollLeft;
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
previewPanel.scrollTop = tab.previewScrollTop;
updateCursorPosition();
});
// Update modified state
setModified(tab.isModified);
// Notify main process about active file
if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.tabSwitched(tab.filePath);
}
}
function setModified(modified) {
const tab = getActiveTab();
if (tab) {
tab.isModified = modified;
// Toggle class directly instead of rebuilding entire tab bar
const tabEl = tabList.querySelector(`.tab-item[data-tab-id="${tab.id}"]`);
if (tabEl) tabEl.classList.toggle('modified', modified);
}
updateTitle();
}
// Create a new tab and switch to it
function createNewTab(filePath, content) {
// Check if file is already open
if (filePath) {
const existing = tabs.find(t => t.filePath === filePath);
if (existing) {
switchToTab(existing.id);
return existing;
}
}
const tab = createTab(filePath, content);
tabs.push(tab);
switchToTab(tab.id);
return tab;
}
// Switch to a specific tab
function switchToTab(tabId) {
if (tabId === activeTabId) return;
// Save current state
saveCurrentTabState();
// Switch
activeTabId = tabId;
const tab = getActiveTab();
if (!tab) return;
loadTabState(tab);
renderTabBar();
updateTitle();
updateSidebarHighlight();
welcomeScreen.classList.add('hidden');
}
// Close a tab
function closeTab(tabId) {
const index = getTabIndex(tabId);
if (index === -1) return;
const tab = tabs[index];
// If modified, confirm
if (tab.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名';
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return;
}
// Remove tab
tabs.splice(index, 1);
// If we closed the active tab, switch to another
if (tabId === activeTabId) {
if (tabs.length === 0) {
// No tabs left - show welcome screen
activeTabId = null;
editor.value = '';
preview.innerHTML = '';
lineNumbers.innerHTML = '';
lineCountCache = 0;
statusSize.textContent = '';
statusSizeDivider.classList.remove('visible');
welcomeScreen.classList.remove('hidden');
document.title = 'MarkLite';
statusText.textContent = '就绪';
if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.tabSwitched(null);
}
} else {
// Switch to nearest tab
const newIndex = Math.min(index, tabs.length - 1);
activeTabId = tabs[newIndex].id;
loadTabState(tabs[newIndex]);
}
}
renderTabBar();
updateTitle();
updateSidebarHighlight();
}
// ===== File Size Limit =====
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ===== File Operations =====
async function handleOpenFile() {
if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.openFile();
if (result && !result.error) {
createNewTab(result.filePath, result.content);
}
} else {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.md,.markdown,.txt';
input.multiple = true;
input.onchange = (e) => {
Array.from(e.target.files).forEach(file => {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!['.md', '.markdown', '.txt'].includes(ext)) return;
if (file.size > MAX_FILE_SIZE) {
alert(`"${file.name}" 过大(${formatFileSize(file.size)}),暂不支持超过 20MB 的文件`);
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
createNewTab(file.name, ev.target.result);
};
reader.readAsText(file);
});
};
input.click();
}
}
async function handleSave() {
const tab = getActiveTab();
if (!tab) return;
if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.saveFile({
filePath: tab.filePath,
content: editor.value
});
if (result.success) {
tab.filePath = result.filePath;
tab.content = editor.value;
tab.isModified = false;
renderTabBar();
updateTitle();
updateSidebarHighlight();
statusText.textContent = '已保存';
setTimeout(() => updateTitle(), 2000);
}
} else {
const blob = new Blob([editor.value], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = tab.filePath || 'untitled.md';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
tab.isModified = false;
renderTabBar();
updateTitle();
}
}
async function handleSaveAs() {
const tab = getActiveTab();
if (!tab) return;
if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.saveFileAs({ content: editor.value });
if (result.success) {
tab.filePath = result.filePath;
tab.content = editor.value;
tab.isModified = false;
renderTabBar();
updateTitle();
updateSidebarHighlight();
statusText.textContent = '已保存';
setTimeout(() => updateTitle(), 2000);
}
} else {
handleSave();
}
}
// ===== View Mode =====
function setViewMode(mode) {
viewMode = mode;
const app = document.getElementById('app');
app.classList.remove('mode-editor', 'mode-preview');
btnSplit.classList.remove('active');
btnEditor.classList.remove('active');
btnPreview.classList.remove('active');
switch (mode) {
case 'split':
btnSplit.classList.add('active');
break;
case 'editor':
btnEditor.classList.add('active');
app.classList.add('mode-editor');
break;
case 'preview':
btnPreview.classList.add('active');
app.classList.add('mode-preview');
renderMarkdown(editor.value);
break;
}
saveSetting('viewMode', mode);
}
// ===== Resizer =====
let isResizing = false;
function initResizer() {
const settings = loadSettings();
if (settings.splitRatio) {
editorPanel.style.flex = `0 0 ${settings.splitRatio}%`;
previewPanel.style.flex = `0 0 ${100 - settings.splitRatio}%`;
}
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const rect = contentWrapper.getBoundingClientRect();
const pct = Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100));
editorPanel.style.flex = `0 0 ${pct}%`;
previewPanel.style.flex = `0 0 ${100 - pct}%`;
saveSetting('splitRatio', pct);
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// ===== Drag & Drop =====
function initDragDrop() {
let dragCounter = 0;
document.addEventListener('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter++;
dropOverlay.classList.remove('hidden');
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter--;
if (dragCounter === 0) dropOverlay.classList.add('hidden');
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
});
document.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter = 0;
dropOverlay.classList.add('hidden');
const files = e.dataTransfer.files;
const allowedExts = ['.md', '.markdown', '.txt'];
for (const file of files) {
const filePath = file.path;
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
if (!allowedExts.includes(ext)) continue;
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
if (file.size > MAX_FILE_SIZE) {
alert(`"${file.name}" 过大(${formatFileSize(file.size)}),暂不支持超过 20MB 的文件`);
continue;
}
if (filePath && typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.readFile(filePath);
if (result.success) {
createNewTab(filePath, result.content);
}
} else {
const reader = new FileReader();
reader.onload = (ev) => createNewTab(file.name, ev.target.result);
reader.readAsText(file);
}
}
});
}
// ===== Keyboard Shortcuts =====
function initKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'o') { e.preventDefault(); handleOpenFile(); }
if (e.ctrlKey && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); }
if (e.ctrlKey && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); }
if (e.ctrlKey && e.key === '1') { e.preventDefault(); setViewMode('split'); }
if (e.ctrlKey && e.key === '2') { e.preventDefault(); setViewMode('editor'); }
if (e.ctrlKey && e.key === '3') { e.preventDefault(); setViewMode('preview'); }
if (e.ctrlKey && e.key === 't') { e.preventDefault(); createNewTab(null, ''); }
if (e.ctrlKey && e.key === 'w') { e.preventDefault(); if (activeTabId) closeTab(activeTabId); }
// Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault();
if (tabs.length > 1) {
const idx = getTabIndex(activeTabId);
const next = e.shiftKey
? (idx - 1 + tabs.length) % tabs.length
: (idx + 1) % tabs.length;
switchToTab(tabs[next].id);
}
}
// Search & Replace
if (e.ctrlKey && e.key === 'f') { e.preventDefault(); openSearch(false); }
if (e.ctrlKey && e.key === 'h') { e.preventDefault(); openSearch(true); }
if (e.key === 'Escape' && !searchBar.classList.contains('hidden')) { e.preventDefault(); closeSearch(); }
if (e.key === 'Enter' && document.activeElement === searchInput) {
e.preventDefault();
e.shiftKey ? findPrev() : findNext();
}
if (e.altKey && e.key === 'c') { e.preventDefault(); toggleSearchOption('case'); }
if (e.altKey && e.key === 'r') { e.preventDefault(); toggleSearchOption('regex'); }
if (e.ctrlKey && e.shiftKey && e.key === 'H') { e.preventDefault(); replaceAll(); }
if (e.ctrlKey && e.shiftKey && e.key === 'G') { e.preventDefault(); replaceCurrent(); }
});
}
// ===== Tab key support =====
function initEditorTab() {
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
// setRangeText 是标准 API,execCommand 已废弃但能保留 undo 历史
const start = editor.selectionStart;
const end = editor.selectionEnd;
if (start !== end) {
// 多行选区:批量缩进
const lines = editor.value.substring(start, end).split('\n');
const indented = lines.map(line => ' ' + line).join('\n');
editor.setRangeText(indented, start, end, 'end');
} else {
// 单光标:插入 4 空格
editor.setRangeText(' ', start, end, 'end');
}
scheduleUpdate();
}
});
}
// ===== External File Modification =====
function initFileWatch() {
if (typeof window.electronAPI === 'undefined') return;
window.electronAPI.onExternalModification((filePath) => {
// Only show banner if the modified file is the active tab
const tab = getActiveTab();
if (tab && tab.filePath === filePath) {
modifiedBanner.classList.remove('hidden');
}
});
btnReload.addEventListener('click', async () => {
const tab = getActiveTab();
if (!tab || !tab.filePath) return;
const result = await window.electronAPI.reloadFile();
if (result.success) {
editor.value = result.content;
tab.content = result.content;
tab.isModified = false;
lineCountCache = 0;
updateLineNumbers();
renderMarkdown(result.content);
updateCursorPosition();
updateFileSize();
cachePreviewPositions();
renderTabBar();
updateTitle();
}
modifiedBanner.classList.add('hidden');
});
btnDismiss.addEventListener('click', () => {
modifiedBanner.classList.add('hidden');
});
}
// ===== Unsaved Changes Warning =====
function initUnsavedWarning() {
const hasUnsaved = () => tabs.some(t => t.isModified);
if (typeof window.electronAPI === 'undefined') {
window.addEventListener('beforeunload', (e) => {
if (hasUnsaved()) { e.preventDefault(); e.returnValue = ''; }
});
return;
}
window.electronAPI.onConfirmClose(() => {
if (!hasUnsaved()) {
window.electronAPI.forceClose();
return;
}
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?');
if (shouldClose) {
window.electronAPI.forceClose();
} else {
window.electronAPI.cancelClose();
}
});
}
// ===== Event Listeners =====
function initEventListeners() {
editor.addEventListener('input', () => {
const tab = getActiveTab();
if (tab) {
tab.isModified = true;
tab.content = editor.value;
setModified(true);
}
scheduleUpdate();
});
editor.addEventListener('scroll', () => {
syncScroll();
// 大文件滚动时重新渲染虚拟行号
if (lineCountCache > 2000) {
renderVirtualLineNumbers(lineCountCache);
}
});
editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition);
editor.addEventListener('select', updateCursorPosition);
btnOpen.addEventListener('click', handleOpenFile);
btnSave.addEventListener('click', handleSave);
btnSplit.addEventListener('click', () => setViewMode('split'));
btnEditor.addEventListener('click', () => setViewMode('editor'));
btnPreview.addEventListener('click', () => setViewMode('preview'));
btnNewTab.addEventListener('click', () => createNewTab(null, ''));
btnWelcomeOpen.addEventListener('click', handleOpenFile);
btnWelcomeNew.addEventListener('click', () => createNewTab(null, ''));
if (typeof window.electronAPI !== 'undefined') {
// Remove old listeners first to prevent stacking
window.electronAPI.removeAllListeners('file:openInTab');
window.electronAPI.removeAllListeners('menu:save');
window.electronAPI.removeAllListeners('menu:saveAs');
window.electronAPI.removeAllListeners('menu:viewMode');
// Main process sends file to open in a new tab
window.electronAPI.onFileOpenInTab((data) => {
createNewTab(data.filePath, data.content);
});
window.electronAPI.onMenuSave(() => handleSave());
window.electronAPI.onMenuSaveAs(() => handleSaveAs());
window.electronAPI.onViewModeChange((mode) => setViewMode(mode));
}
}
// ===== Search & Replace =====
function openSearch(showReplace) {
searchBar.classList.remove('hidden');
replaceRow.classList.toggle('hidden', !showReplace);
searchInput.focus();
// Pre-fill with selected text
const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd);
if (selected && !selected.includes('\n')) {
searchInput.value = selected;
}
doSearch();
}
function closeSearch() {
searchBar.classList.add('hidden');
clearHighlights();
searchMatches = [];
searchIndex = -1;
searchCount.textContent = '';
editor.focus();
}
function toggleSearchOption(type) {
if (type === 'case') {
searchCaseSensitive = !searchCaseSensitive;
btnCase.classList.toggle('active', searchCaseSensitive);
} else {
searchUseRegex = !searchUseRegex;
btnRegex.classList.toggle('active', searchUseRegex);
}
doSearch();
}
function doSearch() {
clearHighlights();
const text = searchInput.value;
if (!text) {
searchMatches = [];
searchIndex = -1;
searchCount.textContent = '';
return;
}
searchMatches = findMatches(text);
if (searchMatches.length > 0) {
// Find the match closest to cursor
const cursor = editor.selectionStart;
searchIndex = searchMatches.findIndex(m => m.start >= cursor);
if (searchIndex === -1) searchIndex = 0;
scrollToMatch(searchIndex);
} else {
searchIndex = -1;
}
searchCount.textContent = searchMatches.length > 0
? `${searchIndex + 1}/${searchMatches.length}`
: '无结果';
highlightMatches();
}
function findMatches(text) {
const content = editor.value;
const matches = [];
if (searchUseRegex) {
try {
const flags = searchCaseSensitive ? 'g' : 'gi';
const regex = new RegExp(text, flags);
let m;
while ((m = regex.exec(content)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length });
if (m[0].length === 0) regex.lastIndex++;
}
} catch { /* invalid regex */ }
} else {
const haystack = searchCaseSensitive ? content : content.toLowerCase();
const needle = searchCaseSensitive ? text : text.toLowerCase();
let idx = 0;
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
matches.push({ start: idx, end: idx + needle.length });
idx += needle.length;
}
}
return matches;
}
function highlightMatches() {
clearHighlights();
if (searchMatches.length === 0) return;
const content = editor.value;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const editorStyle = getComputedStyle(editor);
const paddingTop = parseFloat(editorStyle.paddingTop);
const paddingLeft = parseFloat(editorStyle.paddingLeft);
// Compute line starts for fast lookup
const lineStarts = [0];
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
function posToXY(pos) {
let line = 0;
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] > pos) break;
line = i;
}
const col = pos - lineStarts[line];
return {
top: line * lineHeight + paddingTop,
left: col * 8 + paddingLeft
};
}
const overlay = document.createElement('div');
overlay.id = 'search-highlights';
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden;z-index:1;';
const wrapper = document.getElementById('editor-wrapper');
wrapper.style.position = 'relative';
wrapper.appendChild(overlay);
for (let i = 0; i < searchMatches.length; i++) {
const m = searchMatches[i];
const startPos = posToXY(m.start);
const endPos = posToXY(m.end);
const isCurrent = i === searchIndex;
const div = document.createElement('div');
div.className = 'search-hl' + (isCurrent ? ' current' : '');
div.style.cssText = `position:absolute;top:${startPos.top}px;left:${startPos.left}px;height:${lineHeight}px;width:${Math.max(endPos.left - startPos.left, 8)}px;`;
overlay.appendChild(div);
}
}
function clearHighlights() {
const old = document.getElementById('search-highlights');
if (old) old.remove();
}
function scrollToMatch(index) {
if (index < 0 || index >= searchMatches.length) return;
searchIndex = index;
const m = searchMatches[index];
editor.setSelectionRange(m.start, m.end);
// Scroll the match into view
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const content = editor.value;
let line = 0;
for (let i = 0; i < m.start; i++) {
if (content.charCodeAt(i) === 10) line++;
}
const matchTop = line * lineHeight;
const viewTop = editor.scrollTop;
const viewBottom = viewTop + editor.clientHeight;
if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) {
editor.scrollTop = matchTop - editor.clientHeight / 2;
}
searchCount.textContent = `${index + 1}/${searchMatches.length}`;
highlightMatches();
}
function findNext() {
if (searchMatches.length === 0) return;
scrollToMatch((searchIndex + 1) % searchMatches.length);
}
function findPrev() {
if (searchMatches.length === 0) return;
scrollToMatch((searchIndex - 1 + searchMatches.length) % searchMatches.length);
}
function replaceCurrent() {
if (searchMatches.length === 0 || searchIndex < 0) return;
const m = searchMatches[searchIndex];
const replacement = replaceInput.value;
// Use execCommand to preserve undo history
editor.setSelectionRange(m.start, m.end);
document.execCommand('insertText', false, replacement);
// Re-run search
doSearch();
}
function replaceAll() {
if (searchMatches.length === 0) return;
const replacement = replaceInput.value;
// Replace from end to start to preserve positions
for (let i = searchMatches.length - 1; i >= 0; i--) {
const m = searchMatches[i];
editor.setSelectionRange(m.start, m.end);
document.execCommand('insertText', false, replacement);
}
doSearch();
}
// ===== Sidebar (File Tree) =====
function initSidebar() {
const settings = loadSettings();
if (settings.sidebarCollapsed) sidebar.classList.add('collapsed');
btnOpenFolder.addEventListener('click', openFolder);
if (typeof window.electronAPI !== 'undefined') {
let dirRefreshTimer = null;
window.electronAPI.onDirChanged(() => {
if (!currentRootPath) return;
if (dirRefreshTimer) clearTimeout(dirRefreshTimer);
dirRefreshTimer = setTimeout(() => refreshTree(), 300);
});
}
}
async function openFolder() {
if (typeof window.electronAPI === 'undefined') return;
openFolderDialog();
}
async function openFolderDialog() {
if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openFolderDialog) {
const result = await window.electronAPI.openFolderDialog();
if (result) {
loadFolder(result);
}
}
}
async function loadFolder(dirPath) {
if (typeof window.electronAPI === 'undefined') return;
currentRootPath = dirPath;
const result = await window.electronAPI.readDirTree(dirPath);
if (result.success) {
renderTree(result.tree, dirPath);
sidebar.classList.remove('collapsed');
window.electronAPI.watchDir(dirPath);
}
}
async function refreshTree() {
if (!currentRootPath) return;
const result = await window.electronAPI.readDirTree(currentRootPath);
if (result.success) {
renderTree(result.tree, currentRootPath);
}
}
// Track expanded state
const expandedDirs = new Set();
function renderTree(tree, rootPath) {
sidebarTree.innerHTML = '';
const rootNode = { name: getFileName(rootPath) || rootPath, path: rootPath, type: 'dir', children: tree };
expandedDirs.add(rootPath);
const fragment = document.createDocumentFragment();
buildTreeDOM(fragment, rootNode, 0);
sidebarTree.appendChild(fragment);
}
function buildTreeDOM(parent, node, depth) {
const item = document.createElement('div');
item.className = 'tree-item';
item.style.paddingLeft = (8 + depth * 16) + 'px';
if (node.type === 'dir') {
const isExpanded = expandedDirs.has(node.path);
const arrow = document.createElement('span');
arrow.className = 'tree-arrow' + (isExpanded ? ' expanded' : '');
arrow.innerHTML = '';
item.appendChild(arrow);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '';
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
item.appendChild(name);
parent.appendChild(item);
if (isExpanded && node.children) {
for (const child of node.children) {
buildTreeDOM(parent, child, depth + 1);
}
}
item.addEventListener('click', (e) => {
e.stopPropagation();
if (expandedDirs.has(node.path)) {
expandedDirs.delete(node.path);
} else {
expandedDirs.add(node.path);
}
renderTree(getTreeData(), currentRootPath);
});
} else {
// Spacer for alignment (no arrow)
const spacer = document.createElement('span');
spacer.style.width = '16px';
spacer.style.flexShrink = '0';
item.appendChild(spacer);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '';
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
item.appendChild(name);
// Highlight if this file is open in active tab
const tab = getActiveTab();
if (tab && tab.filePath === node.path) {
item.classList.add('active');
}
item.dataset.path = node.path;
parent.appendChild(item);
item.addEventListener('click', async (e) => {
e.stopPropagation();
// Check if already open
const existing = tabs.find(t => t.filePath === node.path);
if (existing) {
switchToTab(existing.id);
} else {
if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.readFile(node.path);
if (result.success) {
createNewTab(node.path, result.content);
}
}
}
});
}
}
// Cache the last tree data for re-render
let lastTreeData = null;
function getTreeData() {
return lastTreeData || [];
}
// Build list of open files that are NOT inside currentRootPath
function getIndependentFiles() {
if (!tabs.length) return [];
return tabs.filter(t => {
if (!t.filePath) return false; // skip unnamed tabs
if (!currentRootPath) return true; // no folder open → all files are "independent"
// Check if file is inside the current root path
return !t.filePath.startsWith(currentRootPath);
});
}
function renderIndependentFiles() {
const files = getIndependentFiles();
if (files.length === 0) return null;
const section = document.createElement('div');
section.className = 'independent-files-section';
const header = document.createElement('div');
header.className = 'independent-files-header';
header.textContent = '已打开的文件';
section.appendChild(header);
for (const tab of files) {
const item = document.createElement('div');
item.className = 'tree-item independent-file-item';
item.style.paddingLeft = '8px';
item.dataset.path = tab.filePath;
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '';
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = getFileName(tab.filePath);
item.appendChild(name);
// Mark as modified
if (tab.isModified) {
const dot = document.createElement('span');
dot.className = 'independent-modified-dot';
dot.textContent = ' •';
item.appendChild(dot);
}
// Highlight if active
if (tab.id === activeTabId) {
item.classList.add('active');
}
item.addEventListener('click', (e) => {
e.stopPropagation();
switchToTab(tab.id);
});
section.appendChild(item);
}
return section;
}
// Override renderTree to cache data and prepend independent files
const _originalRenderTree = renderTree;
renderTree = function(tree, rootPath) {
lastTreeData = tree;
_originalRenderTree(tree, rootPath);
// Prepend independent files section
const indepSection = renderIndependentFiles();
if (indepSection) {
sidebarTree.insertBefore(indepSection, sidebarTree.firstChild);
}
};
// ===== Search Bar Init =====
function initSearchBar() {
searchInput.addEventListener('input', doSearch);
btnCase.addEventListener('click', () => toggleSearchOption('case'));
btnRegex.addEventListener('click', () => toggleSearchOption('regex'));
btnNext.addEventListener('click', findNext);
btnPrev.addEventListener('click', findPrev);
btnSearchClose.addEventListener('click', closeSearch);
btnReplace.addEventListener('click', replaceCurrent);
btnReplaceAll.addEventListener('click', replaceAll);
}
// ===== Initialize =====
function init() {
initMarked();
initDarkMode();
initResizer();
initDragDrop();
initKeyboard();
initEditorTab();
initPreviewLinks();
initEventListeners();
initFileWatch();
initUnsavedWarning();
initSidebar();
initSearchBar();
const settings = loadSettings();
setViewMode(settings.viewMode || 'split');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();