Files
MarkLite/renderer/renderer.js
T

1053 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 tabList = document.getElementById('tab-list');
// 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;
// ===== 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 v4positional 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 `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
};
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] = '<div class="line-num">' + (i + 1) + '</div>';
}
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] = '<div style="height:' + topPadding + 'px"></div>';
for (let i = 0; i < visibleCount; i++) {
arr[i + 1] = '<div class="line-num">' + (startLine + i + 1) + '</div>';
}
arr[visibleCount + 1] = '<div style="height:' + bottomPadding + 'px"></div>';
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 = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
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 = '就绪';
}
}
// ===== 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();
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();
}
// ===== 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();
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();
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 = mainContent.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);
}
}
});
}
// ===== Tab key support =====
function initEditorTab() {
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
// setRangeText 是标准 APIexecCommand 已废弃但能保留 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));
}
}
// ===== Initialize =====
function init() {
initMarked();
initDarkMode();
initResizer();
initDragDrop();
initKeyboard();
initEditorTab();
initPreviewLinks();
initEventListeners();
initFileWatch();
initUnsavedWarning();
const settings = loadSettings();
setViewMode(settings.viewMode || 'split');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();