Files
MarkLite/renderer/renderer.js
T

925 lines
30 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;
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 是标准 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();
}
})();