// 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 contentWrapper = document.getElementById('content-wrapper'); const tabList = document.getElementById('tab-list'); // Sidebar const sidebar = document.getElementById('sidebar'); const sidebarTree = document.getElementById('sidebar-tree'); const btnOpenFolder = document.getElementById('btn-open-folder'); // Search const searchBar = document.getElementById('search-bar'); const searchInput = document.getElementById('search-input'); const replaceInput = document.getElementById('replace-input'); const searchCount = document.getElementById('search-count'); const replaceRow = document.getElementById('replace-row'); const btnCase = document.getElementById('btn-case'); const btnRegex = document.getElementById('btn-regex'); const btnNext = document.getElementById('btn-next'); const btnPrev = document.getElementById('btn-prev'); const btnSearchClose = document.getElementById('btn-search-close'); const btnReplace = document.getElementById('btn-replace'); const btnReplaceAll = document.getElementById('btn-replace-all'); // 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; let mruTabStack = []; // Most-recently-used tab IDs for Ctrl+Tab // ===== Other State ===== let viewMode = 'split'; let updateTimer = null; let lineCountCache = 0; let currentRootPath = null; // Search state let searchMatches = []; let searchIndex = -1; let searchCaseSensitive = false; let searchUseRegex = false; // ===== Character Width Measurement (#1) ===== const _charWidthCanvas = document.createElement('canvas'); const _charWidthCtx = _charWidthCanvas.getContext('2d'); let _cachedCharWidth = null; function getCharWidth() { if (_cachedCharWidth !== null) return _cachedCharWidth; const style = getComputedStyle(editor); _charWidthCtx.font = `${style.fontSize} ${style.fontFamily}`; _cachedCharWidth = _charWidthCtx.measureText('M').width; return _cachedCharWidth; } // Recalculate on font/theme change new MutationObserver(() => { _cachedCharWidth = null; }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); // ===== Toast Notification (#5) ===== function showToast(message, duration) { duration = duration || 3000; let toast = document.getElementById('toast-notification'); if (!toast) { toast = document.createElement('div'); toast.id = 'toast-notification'; document.getElementById('app').appendChild(toast); } toast.textContent = message; toast.classList.add('show'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('show'), duration); } // ===== 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 `
${highlighted}
`; }; // 处理相对路径图片 → file:// URL renderer.image = function (hrefOrToken, title, text) { const href = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.href : hrefOrToken; const alt = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.text : text; const titleAttr = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.title : title; let src = href; // 相对路径转绝对路径 if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { const tab = getActiveTab(); if (tab && tab.filePath) { const dir = tab.filePath.replace(/[/\\][^/\\]+$/, ''); // 简单路径拼接(不依赖 require('path')) const sep = dir.includes('\\') ? '\\' : '/'; src = 'file://' + (dir + sep + src).replace(/\\/g, '/'); } } const titleStr = titleAttr ? ` title="${titleAttr}"` : ''; return `${alt}`; }; 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] = '
' + (i + 1) + '
'; } 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] = '
'; for (let i = 0; i < visibleCount; i++) { arr[i + 1] = '
' + (startLine + i + 1) + '
'; } arr[visibleCount + 1] = '
'; lineNumbers.innerHTML = arr.join(''); } // ===== Cursor Position ===== function updateCursorPosition() { const text = editor.value; const pos = editor.selectionStart; const textBefore = text.substring(0, pos); const lines = textBefore.split('\n'); statusCursor.textContent = `行 ${lines.length}, 列 ${lines[lines.length - 1].length + 1}`; } // ===== File Size ===== function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function updateFileSize() { const bytes = new Blob([editor.value]).size; statusSize.textContent = formatBytes(bytes); statusSizeDivider.classList.add('visible'); } // ===== Scroll Sync ===== let previewPositions = null; let lineToPreviewMap = null; // 行号 → 预览像素位置的映射 function cachePreviewPositions() { previewPositions = []; for (let i = 0; i < preview.children.length; i++) { previewPositions.push(preview.children[i].offsetTop); } // 构建行号→预览位置的映射表 buildLineToPreviewMap(); } // 通过分析原始 Markdown 文本,建立编辑器行号到预览 DOM 位置的映射 function buildLineToPreviewMap() { if (!previewPositions || previewPositions.length === 0) { lineToPreviewMap = null; return; } const text = editor.value; const lines = text.split('\n'); const totalLines = lines.length; // 找出每个"块级元素"在编辑器中的起始行 // 块级元素:标题、段落、代码块、列表、引用、表格、水平线 const blockStartLines = []; let inCodeBlock = false; for (let i = 0; i < totalLines; i++) { const line = lines[i]; const trimmed = line.trimStart(); // 围栏代码块边界 if (trimmed.startsWith('```')) { if (!inCodeBlock) { blockStartLines.push(i); inCodeBlock = true; } else { inCodeBlock = false; } continue; } if (inCodeBlock) continue; // 块级元素起始行 if ( trimmed.startsWith('#') || // 标题 trimmed.startsWith('- ') || // 无序列表 trimmed.startsWith('* ') || // 无序列表 trimmed.startsWith('+ ') || // 无序列表 /^\d+\.\s/.test(trimmed) || // 有序列表 trimmed.startsWith('> ') || // 引用 trimmed.startsWith('|') || // 表格 trimmed.startsWith('---') || // 水平线 trimmed.startsWith('***') || // 水平线 trimmed.startsWith('___') || // 水平线 (trimmed === '' && i > 0 && lines[i - 1].trim() === '') // 连续空行=段落分隔 ) { blockStartLines.push(i); } } // 去重并排序 const uniqueStartsArr = [...new Set(blockStartLines)].sort((a, b) => a - b); const uniqueStartsSet = new Set(uniqueStartsArr); // 将编辑器行号映射到预览 DOM 位置 // 每个块的起始行对应一个预览元素,用线性插值填充中间行 lineToPreviewMap = new Float32Array(totalLines); if (uniqueStartsArr.length === 0) { // 没有找到块边界,回退到线性映射 for (let i = 0; i < totalLines; i++) { lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1]; } return; } // 将块起始行映射到预览 DOM 索引 const domCount = previewPositions.length; for (let i = 0; i < uniqueStartsArr.length; i++) { const lineIdx = uniqueStartsArr[i]; const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1); lineToPreviewMap[lineIdx] = previewPositions[domIdx]; } // 线性插值填充所有行 for (let i = 0; i < totalLines; i++) { if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue; // 找到前后最近的已映射行 let prevLine = -1, nextLine = -1; for (let j = i - 1; j >= 0; j--) { if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break; } } for (let j = i + 1; j < totalLines; j++) { if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break; } } if (prevLine === -1 && nextLine === -1) { lineToPreviewMap[i] = 0; } else if (prevLine === -1) { lineToPreviewMap[i] = lineToPreviewMap[nextLine]; } else if (nextLine === -1) { lineToPreviewMap[i] = lineToPreviewMap[prevLine]; } else { // 线性插值 const ratio = (i - prevLine) / (nextLine - prevLine); lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine]); } } } function syncScroll() { if (viewMode !== 'split') return; const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const scrollTop = editor.scrollTop; const topLine = Math.floor(scrollTop / lineHeight); if (!previewPositions || previewPositions.length === 0) cachePreviewPositions(); if (!previewPositions || previewPositions.length === 0) return; // 使用行号→预览位置映射表 if (lineToPreviewMap && topLine >= 0 && topLine < lineToPreviewMap.length) { previewPanel.scrollTop = lineToPreviewMap[topLine]; } else { // fallback: 线性比例映射 const totalLines = editor.value.split('\n').length; const ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0; const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1); if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) { previewPanel.scrollTop = previewPositions[targetIndex]; } } } // ===== Schedule Update ===== function scheduleUpdate() { if (updateTimer) clearTimeout(updateTimer); updateTimer = setTimeout(() => { renderMarkdown(editor.value); updateLineNumbers(); updateFileSize(); cachePreviewPositions(); }, 150); } // ===== Helper ===== function getFileName(filePath) { return filePath.split(/[/\\]/).pop(); } // ===== Tab UI ===== function renderTabBar() { tabList.innerHTML = ''; for (const tab of tabs) { const el = document.createElement('div'); el.className = 'tab-item' + (tab.id === activeTabId ? ' active' : '') + (tab.isModified ? ' modified' : ''); el.dataset.tabId = tab.id; const name = document.createElement('span'); name.className = 'tab-name'; name.textContent = tab.filePath ? getFileName(tab.filePath) : '未命名'; el.appendChild(name); const close = document.createElement('button'); close.className = 'tab-close'; close.innerHTML = ''; 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 = '就绪'; } } // ===== Sidebar Highlight ===== // Update sidebar active highlight without re-rendering the entire tree function updateSidebarHighlight() { const tab = getActiveTab(); const activePath = tab ? tab.filePath : null; // No folder open: manage independent files section if (!currentRootPath) { sidebarTree.innerHTML = ''; const indepSection = renderIndependentFiles(); if (indepSection) sidebarTree.appendChild(indepSection); return; } // Folder is open: re-render independent files section (files may have been added/removed) const oldSection = sidebarTree.querySelector('.independent-files-section'); const newSection = renderIndependentFiles(); if (oldSection) oldSection.remove(); if (newSection) sidebarTree.insertBefore(newSection, sidebarTree.firstChild); // Update folder tree items const treeItems = sidebarTree.querySelectorAll('.tree-item:not(.independent-file-item)'); treeItems.forEach(item => { const itemPath = item.dataset.path; if (itemPath) { item.classList.toggle('active', itemPath === activePath); } }); } // ===== 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; lineNumbers.scrollTop = tab.scrollTop; 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(); // MRU: record previous tab before switching if (activeTabId) { mruTabStack = mruTabStack.filter(id => id !== activeTabId); mruTabStack.unshift(activeTabId); } // Switch activeTabId = tabId; const tab = getActiveTab(); if (!tab) return; loadTabState(tab); renderTabBar(); updateTitle(); updateSidebarHighlight(); 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); // Clean MRU stack mruTabStack = mruTabStack.filter(id => id !== tabId); // 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(); updateSidebarHighlight(); } // ===== File Size Limit ===== const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB // ===== File Operations ===== async function handleOpenFile() { if (typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.openFile(); if (result && !result.error) { createNewTab(result.filePath, result.content); } } else { const input = document.createElement('input'); input.type = 'file'; input.accept = '.md,.markdown,.txt'; input.multiple = true; input.onchange = (e) => { Array.from(e.target.files).forEach(file => { const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!['.md', '.markdown', '.txt'].includes(ext)) return; if (file.size > MAX_FILE_SIZE) { alert(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`); return; } const reader = new FileReader(); reader.onload = (ev) => { createNewTab(file.name, ev.target.result); }; reader.readAsText(file); }); }; input.click(); } } async function handleSave() { const tab = getActiveTab(); if (!tab) return; if (typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.saveFile({ filePath: tab.filePath, content: editor.value }); if (result.success) { tab.filePath = result.filePath; tab.content = editor.value; tab.isModified = false; renderTabBar(); updateTitle(); updateSidebarHighlight(); 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(); updateSidebarHighlight(); 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 = contentWrapper.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']; let rejected = 0; for (const file of files) { const filePath = file.path; const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : ''; if (!allowedExts.includes(ext)) { rejected++; continue; } // Electron 主进程已有文件大小检查,此处为浏览器模式兜底 if (file.size > MAX_FILE_SIZE) { showToast(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`); continue; } if (filePath && typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.readFile(filePath); if (result.success) { createNewTab(filePath, result.content); } } else { const reader = new FileReader(); reader.onload = (ev) => createNewTab(file.name, ev.target.result); reader.readAsText(file); } } if (rejected > 0) { showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`); } }); } // ===== 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 (MRU order) if (e.ctrlKey && e.key === 'Tab') { e.preventDefault(); if (tabs.length > 1) { if (e.shiftKey) { // Ctrl+Shift+Tab: go back in MRU history if (mruTabStack.length > 0) { const targetId = mruTabStack.shift(); // Verify target still exists if (tabs.find(t => t.id === targetId)) { switchToTab(targetId); } } } else { // Ctrl+Tab: forward in MRU (just switch to next in array as fallback) const idx = getTabIndex(activeTabId); const next = (idx + 1) % tabs.length; switchToTab(tabs[next].id); } } } // Search & Replace if (e.ctrlKey && e.key === 'f') { e.preventDefault(); openSearch(false); } if (e.ctrlKey && e.key === 'h') { e.preventDefault(); openSearch(true); } if (e.key === 'Escape' && !searchBar.classList.contains('hidden')) { e.preventDefault(); closeSearch(); } if (e.key === 'Enter' && document.activeElement === searchInput) { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.altKey && e.key === 'c') { e.preventDefault(); toggleSearchOption('case'); } if (e.altKey && e.key === 'r') { e.preventDefault(); toggleSearchOption('regex'); } if (e.ctrlKey && e.shiftKey && e.key === 'H') { e.preventDefault(); replaceAll(); } if (e.ctrlKey && e.shiftKey && e.key === 'G') { e.preventDefault(); replaceCurrent(); } }); } // ===== 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(); // 行号跟随编辑器滚动 lineNumbers.scrollTop = editor.scrollTop; // 大文件滚动时重新渲染虚拟行号 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)); } } // ===== Search & Replace ===== function openSearch(showReplace) { searchBar.classList.remove('hidden'); replaceRow.classList.toggle('hidden', !showReplace); searchInput.focus(); // Pre-fill with selected text const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd); if (selected && !selected.includes('\n')) { searchInput.value = selected; } doSearch(); } function closeSearch() { searchBar.classList.add('hidden'); clearHighlights(); searchMatches = []; searchIndex = -1; searchCount.textContent = ''; editor.focus(); } function toggleSearchOption(type) { if (type === 'case') { searchCaseSensitive = !searchCaseSensitive; btnCase.classList.toggle('active', searchCaseSensitive); } else { searchUseRegex = !searchUseRegex; btnRegex.classList.toggle('active', searchUseRegex); } doSearch(); } function doSearch() { clearHighlights(); const text = searchInput.value; if (!text) { searchMatches = []; searchIndex = -1; searchCount.textContent = ''; return; } searchMatches = findMatches(text); if (searchMatches.length > 0) { // Find the match closest to cursor const cursor = editor.selectionStart; searchIndex = searchMatches.findIndex(m => m.start >= cursor); if (searchIndex === -1) searchIndex = 0; scrollToMatch(searchIndex); } else { searchIndex = -1; } searchCount.textContent = searchMatches.length > 0 ? `${searchIndex + 1}/${searchMatches.length}` : '无结果'; highlightMatches(); } function findMatches(text) { const content = editor.value; const matches = []; if (searchUseRegex) { try { const flags = searchCaseSensitive ? 'g' : 'gi'; const regex = new RegExp(text, flags); let m; while ((m = regex.exec(content)) !== null) { matches.push({ start: m.index, end: m.index + m[0].length }); if (m[0].length === 0) regex.lastIndex++; } } catch { /* invalid regex */ } } else { const haystack = searchCaseSensitive ? content : content.toLowerCase(); const needle = searchCaseSensitive ? text : text.toLowerCase(); let idx = 0; while ((idx = haystack.indexOf(needle, idx)) !== -1) { matches.push({ start: idx, end: idx + needle.length }); idx += needle.length; } } return matches; } function highlightMatches() { clearHighlights(); if (searchMatches.length === 0) return; const content = editor.value; const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const editorStyle = getComputedStyle(editor); const paddingTop = parseFloat(editorStyle.paddingTop); const paddingLeft = parseFloat(editorStyle.paddingLeft); // Compute line starts for fast lookup const lineStarts = [0]; for (let i = 0; i < content.length; i++) { if (content.charCodeAt(i) === 10) lineStarts.push(i + 1); } function posToXY(pos) { let line = 0; for (let i = 0; i < lineStarts.length; i++) { if (lineStarts[i] > pos) break; line = i; } const col = pos - lineStarts[line]; return { top: line * lineHeight + paddingTop, left: col * getCharWidth() + paddingLeft }; } const overlay = document.createElement('div'); overlay.id = 'search-highlights'; overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden;z-index:1;'; const wrapper = document.getElementById('editor-wrapper'); wrapper.style.position = 'relative'; wrapper.appendChild(overlay); for (let i = 0; i < searchMatches.length; i++) { const m = searchMatches[i]; const startPos = posToXY(m.start); const endPos = posToXY(m.end); const isCurrent = i === searchIndex; const div = document.createElement('div'); div.className = 'search-hl' + (isCurrent ? ' current' : ''); div.style.cssText = `position:absolute;top:${startPos.top}px;left:${startPos.left}px;height:${lineHeight}px;width:${Math.max(endPos.left - startPos.left, 8)}px;`; overlay.appendChild(div); } } function clearHighlights() { const old = document.getElementById('search-highlights'); if (old) old.remove(); } function scrollToMatch(index) { if (index < 0 || index >= searchMatches.length) return; searchIndex = index; const m = searchMatches[index]; editor.setSelectionRange(m.start, m.end); // Scroll the match into view const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const content = editor.value; let line = 0; for (let i = 0; i < m.start; i++) { if (content.charCodeAt(i) === 10) line++; } const matchTop = line * lineHeight; const viewTop = editor.scrollTop; const viewBottom = viewTop + editor.clientHeight; if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) { editor.scrollTop = matchTop - editor.clientHeight / 2; } searchCount.textContent = `${index + 1}/${searchMatches.length}`; highlightMatches(); } function findNext() { if (searchMatches.length === 0) return; scrollToMatch((searchIndex + 1) % searchMatches.length); } function findPrev() { if (searchMatches.length === 0) return; scrollToMatch((searchIndex - 1 + searchMatches.length) % searchMatches.length); } function replaceCurrent() { if (searchMatches.length === 0 || searchIndex < 0) return; const m = searchMatches[searchIndex]; const replacement = replaceInput.value; // Use execCommand to preserve undo history editor.setSelectionRange(m.start, m.end); document.execCommand('insertText', false, replacement); // Re-run search doSearch(); } function replaceAll() { if (searchMatches.length === 0) return; const replacement = replaceInput.value; // Replace from end to start to preserve positions for (let i = searchMatches.length - 1; i >= 0; i--) { const m = searchMatches[i]; editor.setSelectionRange(m.start, m.end); document.execCommand('insertText', false, replacement); } doSearch(); } // ===== Sidebar (File Tree) ===== function initSidebar() { const settings = loadSettings(); if (settings.sidebarCollapsed) sidebar.classList.add('collapsed'); if (settings.sidebarWidth) { sidebar.style.width = settings.sidebarWidth + 'px'; } // Sidebar resize handle (#10) const handle = document.createElement('div'); handle.className = 'sidebar-resize-handle'; sidebar.appendChild(handle); let isSidebarResizing = false; handle.addEventListener('mousedown', (e) => { isSidebarResizing = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isSidebarResizing) return; const newWidth = Math.max(180, Math.min(500, e.clientX)); sidebar.style.width = newWidth + 'px'; saveSetting('sidebarWidth', newWidth); }); document.addEventListener('mouseup', () => { if (isSidebarResizing) { isSidebarResizing = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }); btnOpenFolder.addEventListener('click', openFolder); if (typeof window.electronAPI !== 'undefined') { let dirRefreshTimer = null; window.electronAPI.onDirChanged(() => { if (!currentRootPath) return; if (dirRefreshTimer) clearTimeout(dirRefreshTimer); dirRefreshTimer = setTimeout(() => refreshTree(), 300); }); } } async function openFolder() { if (typeof window.electronAPI === 'undefined') return; openFolderDialog(); } async function openFolderDialog() { if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openFolderDialog) { const result = await window.electronAPI.openFolderDialog(); if (result) { loadFolder(result); } } } async function loadFolder(dirPath) { if (typeof window.electronAPI === 'undefined') return; currentRootPath = dirPath; const result = await window.electronAPI.readDirTree(dirPath); if (result.success) { renderTree(result.tree, dirPath); sidebar.classList.remove('collapsed'); window.electronAPI.watchDir(dirPath); } } async function refreshTree() { if (!currentRootPath) return; const result = await window.electronAPI.readDirTree(currentRootPath); if (result.success) { renderTree(result.tree, currentRootPath); } } // Track expanded state const expandedDirs = new Set(); function renderTree(tree, rootPath) { sidebarTree.innerHTML = ''; const rootNode = { name: getFileName(rootPath) || rootPath, path: rootPath, type: 'dir', children: tree }; expandedDirs.add(rootPath); const fragment = document.createDocumentFragment(); buildTreeDOM(fragment, rootNode, 0); sidebarTree.appendChild(fragment); } function buildTreeDOM(parent, node, depth) { const item = document.createElement('div'); item.className = 'tree-item'; item.style.paddingLeft = (8 + depth * 16) + 'px'; if (node.type === 'dir') { const isExpanded = expandedDirs.has(node.path); const arrow = document.createElement('span'); arrow.className = 'tree-arrow' + (isExpanded ? ' expanded' : ''); arrow.innerHTML = ''; item.appendChild(arrow); const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.innerHTML = ''; item.appendChild(icon); const name = document.createElement('span'); name.className = 'tree-name'; name.textContent = node.name; item.appendChild(name); parent.appendChild(item); if (isExpanded && node.children) { for (const child of node.children) { buildTreeDOM(parent, child, depth + 1); } } item.addEventListener('click', (e) => { e.stopPropagation(); if (expandedDirs.has(node.path)) { expandedDirs.delete(node.path); } else { expandedDirs.add(node.path); } renderTree(getTreeData(), currentRootPath); }); } else { // Spacer for alignment (no arrow) const spacer = document.createElement('span'); spacer.style.width = '16px'; spacer.style.flexShrink = '0'; item.appendChild(spacer); const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.innerHTML = ''; item.appendChild(icon); const name = document.createElement('span'); name.className = 'tree-name'; name.textContent = node.name; item.appendChild(name); // Highlight if this file is open in active tab const tab = getActiveTab(); if (tab && tab.filePath === node.path) { item.classList.add('active'); } item.dataset.path = node.path; parent.appendChild(item); item.addEventListener('click', async (e) => { e.stopPropagation(); // Check if already open const existing = tabs.find(t => t.filePath === node.path); if (existing) { switchToTab(existing.id); } else { if (typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.readFile(node.path); if (result.success) { createNewTab(node.path, result.content); } } } }); } } // Cache the last tree data for re-render let lastTreeData = null; function getTreeData() { return lastTreeData || []; } // Build list of open files that are NOT inside currentRootPath function getIndependentFiles() { if (!tabs.length) return []; return tabs.filter(t => { if (!t.filePath) return false; // skip unnamed tabs if (!currentRootPath) return true; // no folder open → all files are "independent" // Check if file is inside the current root path return !t.filePath.startsWith(currentRootPath); }); } function renderIndependentFiles() { const files = getIndependentFiles(); if (files.length === 0) return null; const section = document.createElement('div'); section.className = 'independent-files-section'; const header = document.createElement('div'); header.className = 'independent-files-header'; header.textContent = '已打开的文件'; section.appendChild(header); for (const tab of files) { const item = document.createElement('div'); item.className = 'tree-item independent-file-item'; item.style.paddingLeft = '8px'; item.dataset.path = tab.filePath; const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.innerHTML = ''; item.appendChild(icon); const name = document.createElement('span'); name.className = 'tree-name'; name.textContent = getFileName(tab.filePath); item.appendChild(name); // Mark as modified if (tab.isModified) { const dot = document.createElement('span'); dot.className = 'independent-modified-dot'; dot.textContent = ' •'; item.appendChild(dot); } // Highlight if active if (tab.id === activeTabId) { item.classList.add('active'); } item.addEventListener('click', (e) => { e.stopPropagation(); switchToTab(tab.id); }); section.appendChild(item); } return section; } // Override renderTree to cache data, add header, and prepend independent files const _originalRenderTree = renderTree; renderTree = function(tree, rootPath) { lastTreeData = tree; _originalRenderTree(tree, rootPath); // Add folder tree header const folderHeader = document.createElement('div'); folderHeader.className = 'sidebar-section-header'; folderHeader.textContent = '文件夹目录树'; sidebarTree.insertBefore(folderHeader, sidebarTree.firstChild); // Prepend independent files section const indepSection = renderIndependentFiles(); if (indepSection) { sidebarTree.insertBefore(indepSection, sidebarTree.firstChild); } }; // ===== Search Bar Init ===== function initSearchBar() { searchInput.addEventListener('input', doSearch); btnCase.addEventListener('click', () => toggleSearchOption('case')); btnRegex.addEventListener('click', () => toggleSearchOption('regex')); btnNext.addEventListener('click', findNext); btnPrev.addEventListener('click', findPrev); btnSearchClose.addEventListener('click', closeSearch); btnReplace.addEventListener('click', replaceCurrent); btnReplaceAll.addEventListener('click', replaceAll); } // ===== Initialize ===== function init() { initMarked(); initDarkMode(); initResizer(); initDragDrop(); initKeyboard(); initEditorTab(); initPreviewLinks(); initEventListeners(); initFileWatch(); initUnsavedWarning(); initSidebar(); initSearchBar(); const settings = loadSettings(); setViewMode(settings.viewMode || 'split'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();