// 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 `
${highlighted}
`; }; marked.setOptions({ gfm: true, breaks: true, pedantic: false }); marked.use({ renderer }); } } // ===== Markdown Rendering ===== function renderMarkdown(text) { if (typeof marked === 'undefined') { preview.innerHTML = '

错误: Markdown 解析库未加载

'; return; } try { preview.innerHTML = marked.parse(text); } catch (e) { preview.innerHTML = '

渲染错误: ' + e.message + '

'; } } // 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 ===== 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('
', i, '
'); } 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 = ''; 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(); } })();