Files
MarkLite/renderer/renderer.js
T
thzxx eff04606bd fix: 侧边栏高亮跟随标签切换同步更新,支持显示独立打开的文件
- 添加 updateSidebarHighlight() 函数,标签切换时只更新高亮状态而非重建整棵树
- 给树节点添加 data-path 属性用于精确定位高亮
- 新增「已打开的文件」区域,显示不在当前文件夹内的独立文件
- 独立文件支持点击切换、修改状态标识、活跃高亮
- 无文件夹打开时自动显示独立文件列表,全部关闭后隐藏侧边栏
2026-05-21 12:56:50 +08:00

1563 lines
53 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 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;
// ===== 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;
// ===== 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;
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 uniqueStarts = [...new Set(blockStartLines)].sort((a, b) => a - b);
// 将编辑器行号映射到预览 DOM 位置
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
lineToPreviewMap = new Float32Array(totalLines);
if (uniqueStarts.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 < uniqueStarts.length; i++) {
const lineIdx = uniqueStarts[i];
const domIdx = Math.min(Math.floor((i / uniqueStarts.length) * domCount), domCount - 1);
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
}
// 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStarts.includes(i)) continue;
// 找到前后最近的已映射行
let prevLine = -1, nextLine = -1;
for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { prevLine = j; break; }
}
for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(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 = '<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 = '就绪';
}
}
// ===== Sidebar Highlight =====
// Update sidebar active highlight without re-rendering the entire tree
function updateSidebarHighlight() {
const tab = getActiveTab();
const activePath = tab ? tab.filePath : null;
// If no folder is open, just re-render independent files
if (!currentRootPath) {
sidebarTree.innerHTML = '';
const indepSection = renderIndependentFiles();
if (indepSection) sidebarTree.appendChild(indepSection);
return;
}
// 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;
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
previewPanel.scrollTop = tab.previewScrollTop;
updateCursorPosition();
});
// Update modified state
setModified(tab.isModified);
// Update sidebar highlight and independent files
updateSidebarHighlight();
// If no folder is open but we have open files, ensure sidebar is visible
if (!currentRootPath && tabs.length > 0) {
sidebar.classList.remove('collapsed');
} else if (!currentRootPath && tabs.length === 0) {
sidebar.classList.add('collapsed');
}
// 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();
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);
// 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
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ===== 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}" 过大(${formatFileSize(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();
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 = 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'];
for (const file of files) {
const filePath = file.path;
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
if (!allowedExts.includes(ext)) continue;
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
if (file.size > MAX_FILE_SIZE) {
alert(`"${file.name}" 过大(${formatFileSize(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);
}
}
});
}
// ===== 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);
}
}
// 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 是标准 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));
}
}
// ===== 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 * 8 + 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');
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 = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"></polyline></svg>';
item.appendChild(arrow);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>';
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 and prepend independent files
const _originalRenderTree = renderTree;
renderTree = function(tree, rootPath) {
lastTreeData = tree;
_originalRenderTree(tree, rootPath);
// 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();
}
})();