Files
MarkLite/renderer/renderer.js
T
thzxx aac6a0b073 fix: 预览选择复制、关闭超时、链接跳转、监听器泄漏
1. 预览区无法选择复制 — #preview 添加 user-select: text
2. IPC 监听器重复注册 — 注册前先 removeAllListeners 防止堆叠
3. 关闭确认取消后 5s 超时仍强制关闭 — 新增 cancelClose IPC 清除超时
4. 预览区链接点击离开应用 — 拦截点击,通过 shell.openExternal 打开
5. 预览区链接光标样式 — .markdown-body a 添加 cursor: pointer
2026-05-18 13:55:02 +08:00

774 lines
24 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';
}
apply(!!settings.darkMode);
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();
renderer.code = function ({ text, lang }) {
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 });
}
}
// ===== Markdown Rendering =====
function renderMarkdown(text) {
if (typeof marked === 'undefined') {
preview.innerHTML = '<p style="color: red;">错误: Markdown 解析库未加载</p>';
return;
}
try {
preview.innerHTML = marked.parse(text);
} catch (e) {
preview.innerHTML = '<p style="color: red;">渲染错误: ' + e.message + '</p>';
}
}
// 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;
}
// 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 =====
function updateLineNumbers() {
const count = editor.value.split('\n').length;
if (count === lineCountCache) return;
lineCountCache = count;
const fragment = [];
for (let i = 1; i <= count; i++) {
fragment.push('<div class="line-num">', i, '</div>');
}
lineNumbers.innerHTML = fragment.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) {
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 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();
URL.revokeObjectURL(a.href);
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;
for (const file of files) {
const filePath = file.path;
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();
if (!document.execCommand('insertText', false, ' ')) {
const start = editor.selectionStart;
const end = editor.selectionEnd;
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);
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('file:opened');
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);
});
// Legacy: file opened via old dialog
window.electronAPI.onFileOpened((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');
// Create initial empty tab
createNewTab(null, '');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();