925 lines
30 KiB
JavaScript
925 lines
30 KiB
JavaScript
// 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 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 `<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;
|
||
|
||
function cachePreviewPositions() {
|
||
previewPositions = [];
|
||
for (let i = 0; i < preview.children.length; i++) {
|
||
previewPositions.push(preview.children[i].offsetTop);
|
||
}
|
||
}
|
||
|
||
function syncScroll() {
|
||
if (viewMode !== 'split') return;
|
||
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
|
||
const scrollTop = editor.scrollTop;
|
||
const topLine = Math.floor(scrollTop / lineHeight);
|
||
const totalLines = editor.value.split('\n').length;
|
||
|
||
if (!previewPositions || previewPositions.length === 0) cachePreviewPositions();
|
||
if (!previewPositions || previewPositions.length === 0) return;
|
||
|
||
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 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;
|
||
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;
|
||
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 是标准 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));
|
||
}
|
||
}
|
||
|
||
// ===== 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();
|
||
}
|
||
})();
|