diff --git a/renderer/core.js b/renderer/core.js deleted file mode 100644 index 1c177d4..0000000 --- a/renderer/core.js +++ /dev/null @@ -1,454 +0,0 @@ -// MarkLite — Core Module: state, DOM refs, settings, utilities, init orchestration -(function () { - 'use strict'; - - // ===== Shared Namespace ===== - const ML = {}; - window.MarkLite = ML; - - // ===== DOM Elements ===== - ML.editor = document.getElementById('editor'); - ML.preview = document.getElementById('preview'); - ML.lineNumbers = document.getElementById('line-numbers'); - ML.welcomeScreen = document.getElementById('welcome-screen'); - ML.dropOverlay = document.getElementById('drop-overlay'); - ML.modifiedBanner = document.getElementById('modified-banner'); - ML.statusText = document.getElementById('status-text'); - ML.statusCursor = document.getElementById('status-cursor'); - ML.statusSize = document.getElementById('status-size'); - ML.statusSizeDivider = document.querySelector('.status-size-divider'); - ML.resizer = document.getElementById('resizer'); - ML.editorPanel = document.getElementById('editor-panel'); - ML.previewPanel = document.getElementById('preview-panel'); - ML.mainContent = document.getElementById('main-content'); - ML.contentWrapper = document.getElementById('content-wrapper'); - ML.tabList = document.getElementById('tab-list'); - - // Sidebar - ML.sidebar = document.getElementById('sidebar'); - ML.sidebarTree = document.getElementById('sidebar-tree'); - ML.btnOpenFolder = document.getElementById('btn-open-folder'); - - // Search - ML.searchBar = document.getElementById('search-bar'); - ML.searchInput = document.getElementById('search-input'); - ML.replaceInput = document.getElementById('replace-input'); - ML.searchCount = document.getElementById('search-count'); - ML.replaceRow = document.getElementById('replace-row'); - ML.btnCase = document.getElementById('btn-case'); - ML.btnRegex = document.getElementById('btn-regex'); - ML.btnNext = document.getElementById('btn-next'); - ML.btnPrev = document.getElementById('btn-prev'); - ML.btnSearchClose = document.getElementById('btn-search-close'); - ML.btnReplace = document.getElementById('btn-replace'); - ML.btnReplaceAll = document.getElementById('btn-replace-all'); - - // Buttons - ML.btnOpen = document.getElementById('btn-open'); - ML.btnSave = document.getElementById('btn-save'); - ML.btnSplit = document.getElementById('btn-split'); - ML.btnEditor = document.getElementById('btn-editor'); - ML.btnPreview = document.getElementById('btn-preview'); - ML.btnWelcomeOpen = document.getElementById('btn-welcome-open'); - ML.btnWelcomeNew = document.getElementById('btn-welcome-new'); - ML.btnDark = document.getElementById('btn-dark'); - ML.btnReload = document.getElementById('btn-reload'); - ML.btnDismiss = document.getElementById('btn-dismiss'); - ML.btnNewTab = document.getElementById('btn-new-tab'); - - // ===== Shared State ===== - ML.viewMode = 'split'; - ML.updateTimer = null; - ML.lineCountCache = 0; - ML.currentRootPath = null; - ML.MAX_FILE_SIZE = 20 * 1024 * 1024; - - // ===== Settings ===== - ML.loadSettings = function () { - try { return JSON.parse(localStorage.getItem('marklite-settings') || '{}'); } - catch { return {}; } - }; - ML.saveSetting = function (key, value) { - try { - const s = ML.loadSettings(); - s[key] = value; - localStorage.setItem('marklite-settings', JSON.stringify(s)); - } catch { /* ignore */ } - }; - - // ===== Utilities ===== - ML.getFileName = function (filePath) { - return filePath.split(/[/\\]/).pop(); - }; - - ML.formatBytes = function (bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - }; - - // Character width measurement - var _charWidthCanvas = document.createElement('canvas'); - var _charWidthCtx = _charWidthCanvas.getContext('2d'); - var _cachedCharWidth = null; - ML.getCharWidth = function () { - if (_cachedCharWidth !== null) return _cachedCharWidth; - var style = getComputedStyle(ML.editor); - _charWidthCtx.font = style.fontSize + ' ' + style.fontFamily; - _cachedCharWidth = _charWidthCtx.measureText('M').width; - return _cachedCharWidth; - }; - new MutationObserver(function () { _cachedCharWidth = null; }) - .observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - - // Toast notification - ML.showToast = function (message, duration) { - duration = duration || 3000; - var 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(function () { toast.classList.remove('show'); }, duration); - }; - - // ===== Dark Mode ===== - ML.initDarkMode = function () { - var settings = ML.loadSettings(); - var iconDark = document.getElementById('icon-dark'); - var iconLight = document.getElementById('icon-light'); - function apply(isDark) { - document.documentElement.classList.toggle('dark', isDark); - iconDark.style.display = isDark ? 'none' : ''; - iconLight.style.display = isDark ? '' : 'none'; - } - var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - apply(settings.darkMode !== undefined ? !!settings.darkMode : prefersDark); - ML.btnDark.addEventListener('click', function () { - var isDark = !document.documentElement.classList.contains('dark'); - apply(isDark); - ML.saveSetting('darkMode', isDark); - }); - }; - - // ===== File Operations ===== - ML.handleOpenFile = async function () { - if (typeof window.electronAPI !== 'undefined') { - var result = await window.electronAPI.openFile(); - if (result && !result.error) { - ML.createNewTab(result.filePath, result.content); - } - } else { - var input = document.createElement('input'); - input.type = 'file'; - input.accept = '.md,.markdown,.txt'; - input.multiple = true; - input.onchange = function (e) { - Array.from(e.target.files).forEach(function (file) { - var ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); - if (!['.md', '.markdown', '.txt'].includes(ext)) return; - if (file.size > ML.MAX_FILE_SIZE) { - alert('"' + file.name + '" 过大(' + ML.formatBytes(file.size) + '),暂不支持超过 20MB 的文件'); - return; - } - var reader = new FileReader(); - reader.onload = function (ev) { ML.createNewTab(file.name, ev.target.result); }; - reader.readAsText(file); - }); - }; - input.click(); - } - }; - - ML.handleSave = async function () { - var tab = ML.getActiveTab(); - if (!tab) return; - if (typeof window.electronAPI !== 'undefined') { - var result = await window.electronAPI.saveFile({ filePath: tab.filePath, content: ML.editor.value }); - if (result.success) { - tab.filePath = result.filePath; - tab.content = ML.editor.value; - tab.isModified = false; - ML.renderTabBar(); - ML.updateTitle(); - ML.updateSidebarHighlight(); - ML.statusText.textContent = '已保存'; - setTimeout(function () { ML.updateTitle(); }, 2000); - } - } else { - var blob = new Blob([ML.editor.value], { type: 'text/markdown' }); - var a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = tab.filePath || 'untitled.md'; - a.click(); - setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); - tab.isModified = false; - ML.renderTabBar(); - ML.updateTitle(); - } - }; - - ML.handleSaveAs = async function () { - var tab = ML.getActiveTab(); - if (!tab) return; - if (typeof window.electronAPI !== 'undefined') { - var result = await window.electronAPI.saveFileAs({ content: ML.editor.value }); - if (result.success) { - tab.filePath = result.filePath; - tab.content = ML.editor.value; - tab.isModified = false; - ML.renderTabBar(); - ML.updateTitle(); - ML.updateSidebarHighlight(); - ML.statusText.textContent = '已保存'; - setTimeout(function () { ML.updateTitle(); }, 2000); - } - } else { - ML.handleSave(); - } - }; - - // ===== View Mode ===== - ML.setViewMode = function (mode) { - ML.viewMode = mode; - var app = document.getElementById('app'); - app.classList.remove('mode-editor', 'mode-preview'); - ML.btnSplit.classList.remove('active'); - ML.btnEditor.classList.remove('active'); - ML.btnPreview.classList.remove('active'); - switch (mode) { - case 'split': ML.btnSplit.classList.add('active'); break; - case 'editor': ML.btnEditor.classList.add('active'); app.classList.add('mode-editor'); break; - case 'preview': ML.btnPreview.classList.add('active'); app.classList.add('mode-preview'); ML.renderMarkdown(ML.editor.value); break; - } - ML.saveSetting('viewMode', mode); - }; - - // ===== Resizer ===== - ML.initResizer = function () { - var settings = ML.loadSettings(); - if (settings.splitRatio) { - ML.editorPanel.style.flex = '0 0 ' + settings.splitRatio + '%'; - ML.previewPanel.style.flex = '0 0 ' + (100 - settings.splitRatio) + '%'; - } - var isResizing = false; - ML.resizer.addEventListener('mousedown', function (e) { - isResizing = true; - ML.resizer.classList.add('active'); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - e.preventDefault(); - }); - document.addEventListener('mousemove', function (e) { - if (!isResizing) return; - var rect = ML.contentWrapper.getBoundingClientRect(); - var pct = Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100)); - ML.editorPanel.style.flex = '0 0 ' + pct + '%'; - ML.previewPanel.style.flex = '0 0 ' + (100 - pct) + '%'; - ML.saveSetting('splitRatio', pct); - }); - document.addEventListener('mouseup', function () { - if (isResizing) { - isResizing = false; - ML.resizer.classList.remove('active'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - } - }); - }; - - // ===== Drag & Drop ===== - ML.initDragDrop = function () { - var dragCounter = 0; - var allowedExts = ['.md', '.markdown', '.txt']; - document.addEventListener('dragenter', function (e) { e.preventDefault(); e.stopPropagation(); dragCounter++; ML.dropOverlay.classList.remove('hidden'); }); - document.addEventListener('dragleave', function (e) { e.preventDefault(); e.stopPropagation(); dragCounter--; if (dragCounter === 0) ML.dropOverlay.classList.add('hidden'); }); - document.addEventListener('dragover', function (e) { e.preventDefault(); e.stopPropagation(); }); - document.addEventListener('drop', async function (e) { - e.preventDefault(); e.stopPropagation(); dragCounter = 0; ML.dropOverlay.classList.add('hidden'); - var files = e.dataTransfer.files; - var rejected = 0; - for (var _i = 0; _i < files.length; _i++) { - var file = files[_i]; - var filePath = file.path; - var ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : ''; - if (!allowedExts.includes(ext)) { rejected++; continue; } - if (file.size > ML.MAX_FILE_SIZE) { ML.showToast('"' + file.name + '" 过大(' + ML.formatBytes(file.size) + '),暂不支持超过 20MB 的文件'); continue; } - if (filePath && typeof window.electronAPI !== 'undefined') { - var result = await window.electronAPI.readFile(filePath); - if (result.success) ML.createNewTab(filePath, result.content); - } else { - var reader = new FileReader(); - reader.onload = function (ev) { ML.createNewTab(file.name, ev.target.result); }; - reader.readAsText(file); - } - } - if (rejected > 0) ML.showToast('仅支持 .md / .markdown / .txt 文件,已忽略 ' + rejected + ' 个文件'); - }); - }; - - // ===== Keyboard Shortcuts ===== - ML.initKeyboard = function () { - document.addEventListener('keydown', function (e) { - if (e.ctrlKey && e.key === 'o') { e.preventDefault(); ML.handleOpenFile(); } - if (e.ctrlKey && e.key === 's' && !e.shiftKey) { e.preventDefault(); ML.handleSave(); } - if (e.ctrlKey && e.shiftKey && e.key === 'S') { e.preventDefault(); ML.handleSaveAs(); } - if (e.ctrlKey && e.key === '1') { e.preventDefault(); ML.setViewMode('split'); } - if (e.ctrlKey && e.key === '2') { e.preventDefault(); ML.setViewMode('editor'); } - if (e.ctrlKey && e.key === '3') { e.preventDefault(); ML.setViewMode('preview'); } - if (e.ctrlKey && e.key === 't') { e.preventDefault(); ML.createNewTab(null, ''); } - if (e.ctrlKey && e.key === 'w') { e.preventDefault(); if (ML.activeTabId) ML.closeTab(ML.activeTabId); } - if (e.ctrlKey && e.key === 'Tab') { - e.preventDefault(); - if (ML.tabs.length > 1) { - if (e.shiftKey) { - if (ML.mruTabStack.length > 0) { - var targetId = ML.mruTabStack.shift(); - if (ML.tabs.find(function (t) { return t.id === targetId; })) ML.switchToTab(targetId); - } - } else { - var idx = ML.getTabIndex(ML.activeTabId); - var next = (idx + 1) % ML.tabs.length; - ML.switchToTab(ML.tabs[next].id); - } - } - } - if (e.ctrlKey && e.key === 'f') { e.preventDefault(); ML.openSearch(false); } - if (e.ctrlKey && e.key === 'h') { e.preventDefault(); ML.openSearch(true); } - if (e.key === 'Escape' && !ML.searchBar.classList.contains('hidden')) { e.preventDefault(); ML.closeSearch(); } - if (e.key === 'Enter' && document.activeElement === ML.searchInput) { e.preventDefault(); e.shiftKey ? ML.findPrev() : ML.findNext(); } - if (e.altKey && e.key === 'c') { e.preventDefault(); ML.toggleSearchOption('case'); } - if (e.altKey && e.key === 'r') { e.preventDefault(); ML.toggleSearchOption('regex'); } - if (e.ctrlKey && e.shiftKey && e.key === 'H') { e.preventDefault(); ML.replaceAll(); } - if (e.ctrlKey && e.shiftKey && e.key === 'G') { e.preventDefault(); ML.replaceCurrent(); } - }); - }; - - // ===== Tab Key Support ===== - ML.initEditorTab = function () { - ML.editor.addEventListener('keydown', function (e) { - if (e.key === 'Tab') { - e.preventDefault(); - var start = ML.editor.selectionStart; - var end = ML.editor.selectionEnd; - if (start !== end) { - var lines = ML.editor.value.substring(start, end).split('\n'); - var indented = lines.map(function (line) { return ' ' + line; }).join('\n'); - ML.editor.setRangeText(indented, start, end, 'end'); - } else { - ML.editor.setRangeText(' ', start, end, 'end'); - } - ML.scheduleUpdate(); - } - }); - }; - - // ===== External File Modification ===== - ML.initFileWatch = function () { - if (typeof window.electronAPI === 'undefined') return; - window.electronAPI.onExternalModification(function (filePath) { - var tab = ML.getActiveTab(); - if (tab && tab.filePath === filePath) ML.modifiedBanner.classList.remove('hidden'); - }); - ML.btnReload.addEventListener('click', async function () { - var tab = ML.getActiveTab(); - if (!tab || !tab.filePath) return; - var result = await window.electronAPI.reloadFile(); - if (result.success) { - ML.editor.value = result.content; - tab.content = result.content; - tab.isModified = false; - ML.lineCountCache = 0; - ML.updateLineNumbers(); - ML.renderMarkdown(result.content); - ML.updateCursorPosition(); - ML.updateFileSize(); - ML.cachePreviewPositions(); - ML.renderTabBar(); - ML.updateTitle(); - } - ML.modifiedBanner.classList.add('hidden'); - }); - ML.btnDismiss.addEventListener('click', function () { ML.modifiedBanner.classList.add('hidden'); }); - }; - - // ===== Unsaved Changes Warning ===== - ML.initUnsavedWarning = function () { - var hasUnsaved = function () { return ML.tabs.some(function (t) { return t.isModified; }); }; - if (typeof window.electronAPI === 'undefined') { - window.addEventListener('beforeunload', function (e) { if (hasUnsaved()) { e.preventDefault(); e.returnValue = ''; } }); - return; - } - window.electronAPI.onConfirmClose(function () { - if (!hasUnsaved()) { window.electronAPI.forceClose(); return; } - var shouldClose = confirm('有文件尚未保存,确定要关闭吗?'); - if (shouldClose) window.electronAPI.forceClose(); - else window.electronAPI.cancelClose(); - }); - }; - - // ===== Event Listeners ===== - ML.initEventListeners = function () { - ML.editor.addEventListener('input', function () { - var tab = ML.getActiveTab(); - if (tab) { tab.isModified = true; tab.content = ML.editor.value; ML.setModified(true); } - ML.scheduleUpdate(); - }); - ML.editor.addEventListener('scroll', function () { - ML.syncScroll(); - ML.lineNumbers.scrollTop = ML.editor.scrollTop; - if (ML.lineCountCache > 2000) ML.renderVirtualLineNumbers(ML.lineCountCache); - }); - ML.editor.addEventListener('click', ML.updateCursorPosition); - ML.editor.addEventListener('keyup', ML.updateCursorPosition); - ML.editor.addEventListener('select', ML.updateCursorPosition); - - ML.btnOpen.addEventListener('click', ML.handleOpenFile); - ML.btnSave.addEventListener('click', ML.handleSave); - ML.btnSplit.addEventListener('click', function () { ML.setViewMode('split'); }); - ML.btnEditor.addEventListener('click', function () { ML.setViewMode('editor'); }); - ML.btnPreview.addEventListener('click', function () { ML.setViewMode('preview'); }); - ML.btnNewTab.addEventListener('click', function () { ML.createNewTab(null, ''); }); - ML.btnWelcomeOpen.addEventListener('click', ML.handleOpenFile); - ML.btnWelcomeNew.addEventListener('click', function () { ML.createNewTab(null, ''); }); - - if (typeof window.electronAPI !== 'undefined') { - window.electronAPI.removeAllListeners('file:openInTab'); - window.electronAPI.removeAllListeners('menu:save'); - window.electronAPI.removeAllListeners('menu:saveAs'); - window.electronAPI.removeAllListeners('menu:viewMode'); - window.electronAPI.onFileOpenInTab(function (data) { ML.createNewTab(data.filePath, data.content); }); - window.electronAPI.onMenuSave(function () { ML.handleSave(); }); - window.electronAPI.onMenuSaveAs(function () { ML.handleSaveAs(); }); - window.electronAPI.onViewModeChange(function (mode) { ML.setViewMode(mode); }); - } - }; - - // ===== Init ===== - ML.init = function () { - ML.initMarked(); - ML.initDarkMode(); - ML.initResizer(); - ML.initDragDrop(); - ML.initKeyboard(); - ML.initEditorTab(); - ML.initPreviewLinks(); - ML.initEventListeners(); - ML.initFileWatch(); - ML.initUnsavedWarning(); - ML.initSidebar(); - ML.initSearchBar(); - var settings = ML.loadSettings(); - ML.setViewMode(settings.viewMode || 'split'); - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', ML.init); - } else { - ML.init(); - } -})(); diff --git a/renderer/editor.js b/renderer/editor.js deleted file mode 100644 index fd3fa37..0000000 --- a/renderer/editor.js +++ /dev/null @@ -1,147 +0,0 @@ -// MarkLite — Editor Module: line numbers, cursor, file size, scroll sync -(function () { - 'use strict'; - var ML = window.MarkLite; - - // ===== Line Numbers ===== - function countNewlines(text) { - var count = 1; - for (var i = 0, len = text.length; i < len; i++) { - if (text.charCodeAt(i) === 10) count++; - } - return count; - } - - ML.updateLineNumbers = function () { - var count = countNewlines(ML.editor.value); - if (count === ML.lineCountCache) return; - ML.lineCountCache = count; - if (count > 2000) { - ML.renderVirtualLineNumbers(count); - } else { - var arr = new Array(count); - for (var i = 0; i < count; i++) arr[i] = '
' + (i + 1) + '
'; - ML.lineNumbers.innerHTML = arr.join(''); - } - }; - - ML.renderVirtualLineNumbers = function (totalLines) { - var lineHeight = 20.8; - var scrollTop = ML.editor.scrollTop; - var viewportHeight = ML.editor.clientHeight; - var buffer = 20; - var startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer); - var endLine = Math.min(totalLines, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer); - var visibleCount = endLine - startLine; - var topPadding = startLine * lineHeight; - var bottomPadding = (totalLines - endLine) * lineHeight; - var arr = new Array(visibleCount + 2); - arr[0] = '
'; - for (var i = 0; i < visibleCount; i++) arr[i + 1] = '
' + (startLine + i + 1) + '
'; - arr[visibleCount + 1] = '
'; - ML.lineNumbers.innerHTML = arr.join(''); - }; - - // ===== Cursor Position ===== - ML.updateCursorPosition = function () { - var text = ML.editor.value; - var pos = ML.editor.selectionStart; - var textBefore = text.substring(0, pos); - var lines = textBefore.split('\n'); - ML.statusCursor.textContent = '行 ' + lines.length + ', 列 ' + (lines[lines.length - 1].length + 1); - }; - - // ===== File Size ===== - ML.updateFileSize = function () { - var bytes = new Blob([ML.editor.value]).size; - ML.statusSize.textContent = ML.formatBytes(bytes); - ML.statusSizeDivider.classList.add('visible'); - }; - - // ===== Scroll Sync ===== - var previewPositions = null; - var lineToPreviewMap = null; - - ML.cachePreviewPositions = function () { - previewPositions = []; - for (var i = 0; i < ML.preview.children.length; i++) previewPositions.push(ML.preview.children[i].offsetTop); - buildLineToPreviewMap(); - }; - - function buildLineToPreviewMap() { - if (!previewPositions || previewPositions.length === 0) { lineToPreviewMap = null; return; } - var text = ML.editor.value; - var lines = text.split('\n'); - var totalLines = lines.length; - var blockStartLines = []; - var inCodeBlock = false; - for (var i = 0; i < totalLines; i++) { - var trimmed = lines[i].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); - } - } - var uniqueStartsArr = [].concat(_toConsumableArray(new Set(blockStartLines))).sort(function (a, b) { return a - b; }); - var uniqueStartsSet = new Set(uniqueStartsArr); - lineToPreviewMap = new Float32Array(totalLines); - if (uniqueStartsArr.length === 0) { - for (var _i = 0; _i < totalLines; _i++) lineToPreviewMap[_i] = (_i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1]; - return; - } - var domCount = previewPositions.length; - for (var _i2 = 0; _i2 < uniqueStartsArr.length; _i2++) { - var lineIdx = uniqueStartsArr[_i2]; - var domIdx = Math.min(Math.floor((_i2 / uniqueStartsArr.length) * domCount), domCount - 1); - lineToPreviewMap[lineIdx] = previewPositions[domIdx]; - } - for (var _i3 = 0; _i3 < totalLines; _i3++) { - if (lineToPreviewMap[_i3] !== 0 && uniqueStartsSet.has(_i3)) continue; - var prevLine = -1, nextLine = -1; - for (var j = _i3 - 1; j >= 0; j--) { if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break; } } - for (var _j = _i3 + 1; _j < totalLines; _j++) { if (lineToPreviewMap[_j] !== 0 || uniqueStartsSet.has(_j)) { nextLine = _j; break; } } - if (prevLine === -1 && nextLine === -1) lineToPreviewMap[_i3] = 0; - else if (prevLine === -1) lineToPreviewMap[_i3] = lineToPreviewMap[nextLine]; - else if (nextLine === -1) lineToPreviewMap[_i3] = lineToPreviewMap[prevLine]; - else { var ratio = (_i3 - prevLine) / (nextLine - prevLine); lineToPreviewMap[_i3] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine]); } - } - } - - function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } return Array.from(arr); } - - ML.syncScroll = function () { - if (ML.viewMode !== 'split') return; - var lineHeight = parseFloat(getComputedStyle(ML.editor).lineHeight) || 20.8; - var scrollTop = ML.editor.scrollTop; - var topLine = Math.floor(scrollTop / lineHeight); - if (!previewPositions || previewPositions.length === 0) ML.cachePreviewPositions(); - if (!previewPositions || previewPositions.length === 0) return; - if (lineToPreviewMap && topLine >= 0 && topLine < lineToPreviewMap.length) { - ML.previewPanel.scrollTop = lineToPreviewMap[topLine]; - } else { - var totalLines = ML.editor.value.split('\n').length; - var ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0; - var targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1); - if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) ML.previewPanel.scrollTop = previewPositions[targetIndex]; - } - }; - - // ===== Schedule Update ===== - ML.scheduleUpdate = function () { - if (ML.updateTimer) clearTimeout(ML.updateTimer); - ML.updateTimer = setTimeout(function () { - ML.renderMarkdown(ML.editor.value); - ML.updateLineNumbers(); - ML.updateFileSize(); - ML.cachePreviewPositions(); - }, 150); - }; -})(); diff --git a/renderer/index.html b/renderer/index.html index fc63807..9913871 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -210,11 +210,6 @@ - - - - - - + diff --git a/renderer/markdown.js b/renderer/markdown.js deleted file mode 100644 index 7fd95e6..0000000 --- a/renderer/markdown.js +++ /dev/null @@ -1,103 +0,0 @@ -// MarkLite — Markdown Module: marked.js init, sanitizer, rendering, preview links -(function () { - 'use strict'; - var ML = window.MarkLite; - - // ===== HTML Sanitizer ===== - var DANGEROUS_TAGS = new Set(['script', 'iframe', 'object', 'embed', 'applet', 'form', 'base', 'meta', 'link', 'style']); - var 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) { - var doc = new DOMParser().parseFromString(html, 'text/html'); - var walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT); - var toRemove = []; - while (walker.nextNode()) { - var el = walker.currentNode; - var tag = el.tagName.toLowerCase(); - if (DANGEROUS_TAGS.has(tag)) { toRemove.push(el); continue; } - var attrs = Array.from(el.attributes); - for (var i = 0; i < attrs.length; i++) { - var name = attrs[i].name.toLowerCase(); - if (DANGEROUS_ATTRS.has(name) || name.startsWith('on')) el.removeAttribute(attrs[i].name); - else if ((name === 'href' || name === 'action' || name === 'formaction' || name === 'xlink:href') && /^\s*javascript:/i.test(attrs[i].value)) el.removeAttribute(attrs[i].name); - else if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(attrs[i].value)) el.removeAttribute(attrs[i].name); - } - } - for (var _i = 0; _i < toRemove.length; _i++) toRemove[_i].parentNode.removeChild(toRemove[_i]); - return doc.body.innerHTML; - } - - // ===== Rendering ===== - ML.renderMarkdown = function (text) { - if (typeof marked === 'undefined') { ML.preview.textContent = '错误: Markdown 解析库未加载'; ML.preview.style.color = 'red'; return; } - try { - var raw = marked.parse(text); - ML.preview.innerHTML = sanitizeHTML(raw); - ML.preview.style.color = ''; - } catch (e) { ML.preview.textContent = '渲染错误: ' + e.message; ML.preview.style.color = 'red'; } - }; - - // ===== Init marked.js ===== - ML.initMarked = function () { - if (typeof marked === 'undefined') return; - var renderer = new marked.Renderer(); - renderer.code = function (codeOrToken, legacyLang) { - var text = (typeof codeOrToken === 'object' && codeOrToken !== null) ? codeOrToken.text : codeOrToken; - var lang = (typeof codeOrToken === 'object' && codeOrToken !== null) ? codeOrToken.lang : legacyLang; - var highlighted = text; - if (typeof hljs !== 'undefined') { - if (lang && hljs.getLanguage(lang)) { try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* */ } } - else { try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* */ } } - } - var langClass = lang ? ' language-' + lang : ''; - return '
' + highlighted + '
'; - }; - renderer.image = function (hrefOrToken, title, text) { - var href = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.href : hrefOrToken; - var alt = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.text : text; - var titleAttr = (typeof hrefOrToken === 'object' && hrefOrToken !== null) ? hrefOrToken.title : title; - var src = href; - if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { - var tab = ML.getActiveTab(); - if (tab && tab.filePath) { - var dir = tab.filePath.replace(/[/\\][^/\\]+$/, ''); - var sep = dir.includes('\\') ? '\\' : '/'; - src = 'file://' + (dir + sep + src).replace(/\\/g, '/'); - } - } - var titleStr = titleAttr ? ' title="' + titleAttr + '"' : ''; - return '' + alt + ''; - }; - marked.setOptions({ gfm: true, breaks: true, pedantic: false }); - marked.use({ renderer: renderer }); - }; - - // ===== Preview Links ===== - ML.initPreviewLinks = function () { - ML.preview.addEventListener('click', function (e) { - var link = e.target.closest('a'); - if (!link) return; - e.preventDefault(); - var href = link.getAttribute('href'); - if (!href) return; - if (href.startsWith('#')) { var target = ML.preview.querySelector(href); if (target) target.scrollIntoView({ behavior: 'smooth' }); return; } - try { var url = new URL(href); if (url.protocol !== 'http:' && url.protocol !== 'https:') return; } catch (err) { return; } - if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openExternal) window.electronAPI.openExternal(href); - else window.open(href, '_blank', 'noopener'); - }); - }; -})(); diff --git a/renderer/renderer.js b/renderer/renderer.js new file mode 100644 index 0000000..b5e0590 --- /dev/null +++ b/renderer/renderer.js @@ -0,0 +1,1663 @@ +// 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(); + } +})(); diff --git a/renderer/search.js b/renderer/search.js deleted file mode 100644 index 385365f..0000000 --- a/renderer/search.js +++ /dev/null @@ -1,158 +0,0 @@ -// MarkLite — Search & Replace Module -(function () { - 'use strict'; - var ML = window.MarkLite; - - var searchMatches = []; - var searchIndex = -1; - var searchCaseSensitive = false; - var searchUseRegex = false; - - ML.openSearch = function (showReplace) { - ML.searchBar.classList.remove('hidden'); - ML.replaceRow.classList.toggle('hidden', !showReplace); - ML.searchInput.focus(); - var selected = ML.editor.value.substring(ML.editor.selectionStart, ML.editor.selectionEnd); - if (selected && !selected.includes('\n')) ML.searchInput.value = selected; - doSearch(); - }; - - ML.closeSearch = function () { - ML.searchBar.classList.add('hidden'); - clearHighlights(); - searchMatches = []; searchIndex = -1; - ML.searchCount.textContent = ''; - ML.editor.focus(); - }; - - ML.toggleSearchOption = function (type) { - if (type === 'case') { searchCaseSensitive = !searchCaseSensitive; ML.btnCase.classList.toggle('active', searchCaseSensitive); } - else { searchUseRegex = !searchUseRegex; ML.btnRegex.classList.toggle('active', searchUseRegex); } - doSearch(); - }; - - function doSearch() { - clearHighlights(); - var text = ML.searchInput.value; - if (!text) { searchMatches = []; searchIndex = -1; ML.searchCount.textContent = ''; return; } - searchMatches = findMatches(text); - if (searchMatches.length > 0) { - var cursor = ML.editor.selectionStart; - searchIndex = searchMatches.findIndex(function (m) { return m.start >= cursor; }); - if (searchIndex === -1) searchIndex = 0; - scrollToMatch(searchIndex); - } else { searchIndex = -1; } - ML.searchCount.textContent = searchMatches.length > 0 ? (searchIndex + 1) + '/' + searchMatches.length : '无结果'; - highlightMatches(); - } - - function findMatches(text) { - var content = ML.editor.value; - var matches = []; - if (searchUseRegex) { - try { - var flags = searchCaseSensitive ? 'g' : 'gi'; - var regex = new RegExp(text, flags); - var 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 (e) { /* invalid regex */ } - } else { - var haystack = searchCaseSensitive ? content : content.toLowerCase(); - var needle = searchCaseSensitive ? text : text.toLowerCase(); - var 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; - var content = ML.editor.value; - var lineHeight = parseFloat(getComputedStyle(ML.editor).lineHeight) || 20.8; - var editorStyle = getComputedStyle(ML.editor); - var paddingTop = parseFloat(editorStyle.paddingTop); - var paddingLeft = parseFloat(editorStyle.paddingLeft); - var lineStarts = [0]; - for (var i = 0; i < content.length; i++) { if (content.charCodeAt(i) === 10) lineStarts.push(i + 1); } - function posToXY(pos) { - var line = 0; - for (var _i = 0; _i < lineStarts.length; _i++) { if (lineStarts[_i] > pos) break; line = _i; } - return { top: line * lineHeight + paddingTop, left: (pos - lineStarts[line]) * ML.getCharWidth() + paddingLeft }; - } - var 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;'; - var wrapper = document.getElementById('editor-wrapper'); - wrapper.style.position = 'relative'; - wrapper.appendChild(overlay); - for (var _i2 = 0; _i2 < searchMatches.length; _i2++) { - var m = searchMatches[_i2]; - var startPos = posToXY(m.start); - var endPos = posToXY(m.end); - var isCurrent = _i2 === searchIndex; - var 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() { var old = document.getElementById('search-highlights'); if (old) old.remove(); } - - function scrollToMatch(index) { - if (index < 0 || index >= searchMatches.length) return; - searchIndex = index; - var m = searchMatches[index]; - ML.editor.setSelectionRange(m.start, m.end); - var lineHeight = parseFloat(getComputedStyle(ML.editor).lineHeight) || 20.8; - var content = ML.editor.value; - var line = 0; - for (var i = 0; i < m.start; i++) { if (content.charCodeAt(i) === 10) line++; } - var matchTop = line * lineHeight; - var viewTop = ML.editor.scrollTop; - var viewBottom = viewTop + ML.editor.clientHeight; - if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) ML.editor.scrollTop = matchTop - ML.editor.clientHeight / 2; - ML.searchCount.textContent = (index + 1) + '/' + searchMatches.length; - highlightMatches(); - } - - ML.findNext = function () { if (searchMatches.length === 0) return; scrollToMatch((searchIndex + 1) % searchMatches.length); }; - ML.findPrev = function () { if (searchMatches.length === 0) return; scrollToMatch((searchIndex - 1 + searchMatches.length) % searchMatches.length); }; - - ML.replaceCurrent = function () { - if (searchMatches.length === 0 || searchIndex < 0) return; - var m = searchMatches[searchIndex]; - ML.editor.setSelectionRange(m.start, m.end); - document.execCommand('insertText', false, ML.replaceInput.value); - doSearch(); - }; - - ML.replaceAll = function () { - if (searchMatches.length === 0) return; - var replacement = ML.replaceInput.value; - for (var i = searchMatches.length - 1; i >= 0; i--) { - var m = searchMatches[i]; - ML.editor.setSelectionRange(m.start, m.end); - document.execCommand('insertText', false, replacement); - } - doSearch(); - }; - - ML.initSearchBar = function () { - ML.searchInput.addEventListener('input', doSearch); - ML.btnCase.addEventListener('click', function () { ML.toggleSearchOption('case'); }); - ML.btnRegex.addEventListener('click', function () { ML.toggleSearchOption('regex'); }); - ML.btnNext.addEventListener('click', ML.findNext); - ML.btnPrev.addEventListener('click', ML.findPrev); - ML.btnSearchClose.addEventListener('click', ML.closeSearch); - ML.btnReplace.addEventListener('click', ML.replaceCurrent); - ML.btnReplaceAll.addEventListener('click', ML.replaceAll); - }; -})(); diff --git a/renderer/sidebar.js b/renderer/sidebar.js deleted file mode 100644 index 4600043..0000000 --- a/renderer/sidebar.js +++ /dev/null @@ -1,208 +0,0 @@ -// MarkLite — Sidebar Module: file tree, independent files, resize handle -(function () { - 'use strict'; - var ML = window.MarkLite; - - var expandedDirs = new Set(); - var lastTreeData = null; - - // ===== Sidebar Highlight ===== - ML.updateSidebarHighlight = function () { - var tab = ML.getActiveTab(); - var activePath = tab ? tab.filePath : null; - if (!ML.currentRootPath) { - ML.sidebarTree.innerHTML = ''; - var indep = renderIndependentFiles(); - if (indep) ML.sidebarTree.appendChild(indep); - return; - } - var oldSection = ML.sidebarTree.querySelector('.independent-files-section'); - var newSection = renderIndependentFiles(); - if (oldSection) oldSection.remove(); - if (newSection) ML.sidebarTree.insertBefore(newSection, ML.sidebarTree.firstChild); - var treeItems = ML.sidebarTree.querySelectorAll('.tree-item:not(.independent-file-item)'); - treeItems.forEach(function (item) { - var itemPath = item.dataset.path; - if (itemPath) item.classList.toggle('active', itemPath === activePath); - }); - }; - - // ===== Independent Files ===== - function getIndependentFiles() { - if (!ML.tabs.length) return []; - return ML.tabs.filter(function (t) { - if (!t.filePath) return false; - if (!ML.currentRootPath) return true; - return !t.filePath.startsWith(ML.currentRootPath); - }); - } - - function renderIndependentFiles() { - var files = getIndependentFiles(); - if (files.length === 0) return null; - var section = document.createElement('div'); - section.className = 'independent-files-section'; - var header = document.createElement('div'); - header.className = 'independent-files-header'; - header.textContent = '已打开的文件'; - section.appendChild(header); - files.forEach(function (tab) { - var item = document.createElement('div'); - item.className = 'tree-item independent-file-item'; - item.style.paddingLeft = '8px'; - item.dataset.path = tab.filePath; - var icon = document.createElement('span'); - icon.className = 'tree-icon'; - icon.innerHTML = ''; - item.appendChild(icon); - var name = document.createElement('span'); - name.className = 'tree-name'; - name.textContent = ML.getFileName(tab.filePath); - item.appendChild(name); - if (tab.isModified) { - var dot = document.createElement('span'); - dot.className = 'independent-modified-dot'; - dot.textContent = ' •'; - item.appendChild(dot); - } - if (tab.id === ML.activeTabId) item.classList.add('active'); - item.addEventListener('click', function (e) { e.stopPropagation(); ML.switchToTab(tab.id); }); - section.appendChild(item); - }); - return section; - } - - // ===== Folder Operations ===== - async function openFolderDialog() { - if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openFolderDialog) { - var result = await window.electronAPI.openFolderDialog(); - if (result) loadFolder(result); - } - } - - async function loadFolder(dirPath) { - if (typeof window.electronAPI === 'undefined') return; - ML.currentRootPath = dirPath; - var result = await window.electronAPI.readDirTree(dirPath); - if (result.success) { - renderTree(result.tree, dirPath); - ML.sidebar.classList.remove('collapsed'); - window.electronAPI.watchDir(dirPath); - } - } - - async function refreshTree() { - if (!ML.currentRootPath) return; - var result = await window.electronAPI.readDirTree(ML.currentRootPath); - if (result.success) renderTree(result.tree, ML.currentRootPath); - } - - // ===== Tree Rendering ===== - function renderTreeOriginal(tree, rootPath) { - ML.sidebarTree.innerHTML = ''; - var rootNode = { name: ML.getFileName(rootPath) || rootPath, path: rootPath, type: 'dir', children: tree }; - expandedDirs.add(rootPath); - var fragment = document.createDocumentFragment(); - buildTreeDOM(fragment, rootNode, 0); - ML.sidebarTree.appendChild(fragment); - } - - function renderTree(tree, rootPath) { - lastTreeData = tree; - renderTreeOriginal(tree, rootPath); - var folderHeader = document.createElement('div'); - folderHeader.className = 'sidebar-section-header'; - folderHeader.textContent = '文件夹目录树'; - ML.sidebarTree.insertBefore(folderHeader, ML.sidebarTree.firstChild); - var indepSection = renderIndependentFiles(); - if (indepSection) ML.sidebarTree.insertBefore(indepSection, ML.sidebarTree.firstChild); - } - - function buildTreeDOM(parent, node, depth) { - var item = document.createElement('div'); - item.className = 'tree-item'; - item.style.paddingLeft = (8 + depth * 16) + 'px'; - if (node.type === 'dir') { - var isExpanded = expandedDirs.has(node.path); - var arrow = document.createElement('span'); - arrow.className = 'tree-arrow' + (isExpanded ? ' expanded' : ''); - arrow.innerHTML = ''; - item.appendChild(arrow); - var icon = document.createElement('span'); - icon.className = 'tree-icon'; - icon.innerHTML = ''; - item.appendChild(icon); - var name = document.createElement('span'); - name.className = 'tree-name'; - name.textContent = node.name; - item.appendChild(name); - parent.appendChild(item); - if (isExpanded && node.children) { - node.children.forEach(function (child) { buildTreeDOM(parent, child, depth + 1); }); - } - item.addEventListener('click', function (e) { - e.stopPropagation(); - if (expandedDirs.has(node.path)) expandedDirs.delete(node.path); - else expandedDirs.add(node.path); - renderTree(lastTreeData || [], ML.currentRootPath); - }); - } else { - var spacer = document.createElement('span'); - spacer.style.width = '16px'; - spacer.style.flexShrink = '0'; - item.appendChild(spacer); - var fileIcon = document.createElement('span'); - fileIcon.className = 'tree-icon'; - fileIcon.innerHTML = ''; - item.appendChild(fileIcon); - var fileName = document.createElement('span'); - fileName.className = 'tree-name'; - fileName.textContent = node.name; - item.appendChild(fileName); - var tab = ML.getActiveTab(); - if (tab && tab.filePath === node.path) item.classList.add('active'); - item.dataset.path = node.path; - parent.appendChild(item); - item.addEventListener('click', async function (e) { - e.stopPropagation(); - var existing = ML.tabs.find(function (t) { return t.filePath === node.path; }); - if (existing) { ML.switchToTab(existing.id); } - else if (typeof window.electronAPI !== 'undefined') { - var result = await window.electronAPI.readFile(node.path); - if (result.success) ML.createNewTab(node.path, result.content); - } - }); - } - } - - // ===== Init ===== - ML.initSidebar = function () { - var settings = ML.loadSettings(); - if (settings.sidebarCollapsed) ML.sidebar.classList.add('collapsed'); - if (settings.sidebarWidth) ML.sidebar.style.width = settings.sidebarWidth + 'px'; - - // Resize handle - var handle = document.createElement('div'); - handle.className = 'sidebar-resize-handle'; - ML.sidebar.appendChild(handle); - var isResizing = false; - handle.addEventListener('mousedown', function (e) { isResizing = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); - document.addEventListener('mousemove', function (e) { - if (!isResizing) return; - var newWidth = Math.max(180, Math.min(500, e.clientX)); - ML.sidebar.style.width = newWidth + 'px'; - ML.saveSetting('sidebarWidth', newWidth); - }); - document.addEventListener('mouseup', function () { if (isResizing) { isResizing = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }); - - ML.btnOpenFolder.addEventListener('click', function () { if (typeof window.electronAPI !== 'undefined') openFolderDialog(); }); - if (typeof window.electronAPI !== 'undefined') { - var dirRefreshTimer = null; - window.electronAPI.onDirChanged(function () { - if (!ML.currentRootPath) return; - if (dirRefreshTimer) clearTimeout(dirRefreshTimer); - dirRefreshTimer = setTimeout(function () { refreshTree(); }, 300); - }); - } - }; -})(); diff --git a/renderer/tabs.js b/renderer/tabs.js deleted file mode 100644 index b75201b..0000000 --- a/renderer/tabs.js +++ /dev/null @@ -1,156 +0,0 @@ -// MarkLite — Tabs Module -(function () { - 'use strict'; - var ML = window.MarkLite; - - // ===== Tab State ===== - ML.tabs = []; - ML.activeTabId = null; - var tabIdCounter = 0; - ML.mruTabStack = []; - - ML.createTab = function (filePath, content) { - var id = ++tabIdCounter; - return { id: id, filePath: filePath || null, content: content || '', isModified: false, scrollTop: 0, scrollLeft: 0, selectionStart: 0, selectionEnd: 0, previewScrollTop: 0 }; - }; - - ML.getActiveTab = function () { return ML.tabs.find(function (t) { return t.id === ML.activeTabId; }) || null; }; - ML.getTabIndex = function (tabId) { return ML.tabs.findIndex(function (t) { return t.id === tabId; }); }; - - ML.saveCurrentTabState = function () { - var tab = ML.getActiveTab(); - if (!tab) return; - tab.content = ML.editor.value; - tab.scrollTop = ML.editor.scrollTop; - tab.scrollLeft = ML.editor.scrollLeft; - tab.selectionStart = ML.editor.selectionStart; - tab.selectionEnd = ML.editor.selectionEnd; - tab.previewScrollTop = ML.previewPanel.scrollTop; - }; - - ML.loadTabState = function (tab) { - ML.editor.focus(); ML.editor.select(); - var inserted = document.execCommand('insertText', false, tab.content); - if (!inserted) ML.editor.value = tab.content; - ML.lineCountCache = 0; - ML.updateLineNumbers(); - ML.renderMarkdown(tab.content); - ML.updateFileSize(); - ML.cachePreviewPositions(); - requestAnimationFrame(function () { - ML.editor.scrollTop = tab.scrollTop; - ML.editor.scrollLeft = tab.scrollLeft; - ML.lineNumbers.scrollTop = tab.scrollTop; - ML.editor.setSelectionRange(tab.selectionStart, tab.selectionEnd); - ML.previewPanel.scrollTop = tab.previewScrollTop; - ML.updateCursorPosition(); - }); - ML.setModified(tab.isModified); - if (typeof window.electronAPI !== 'undefined') window.electronAPI.tabSwitched(tab.filePath); - }; - - ML.setModified = function (modified) { - var tab = ML.getActiveTab(); - if (tab) { - tab.isModified = modified; - var tabEl = ML.tabList.querySelector('.tab-item[data-tab-id="' + tab.id + '"]'); - if (tabEl) tabEl.classList.toggle('modified', modified); - } - ML.updateTitle(); - }; - - ML.createNewTab = function (filePath, content) { - if (filePath) { - var existing = ML.tabs.find(function (t) { return t.filePath === filePath; }); - if (existing) { ML.switchToTab(existing.id); return existing; } - } - var tab = ML.createTab(filePath, content); - ML.tabs.push(tab); - ML.switchToTab(tab.id); - return tab; - }; - - ML.switchToTab = function (tabId) { - if (tabId === ML.activeTabId) return; - ML.saveCurrentTabState(); - if (ML.activeTabId) { - ML.mruTabStack = ML.mruTabStack.filter(function (id) { return id !== ML.activeTabId; }); - ML.mruTabStack.unshift(ML.activeTabId); - } - ML.activeTabId = tabId; - var tab = ML.getActiveTab(); - if (!tab) return; - ML.loadTabState(tab); - ML.renderTabBar(); - ML.updateTitle(); - ML.updateSidebarHighlight(); - ML.welcomeScreen.classList.add('hidden'); - }; - - ML.closeTab = function (tabId) { - var index = ML.getTabIndex(tabId); - if (index === -1) return; - var tab = ML.tabs[index]; - if (tab.isModified) { - var name = tab.filePath ? ML.getFileName(tab.filePath) : '未命名'; - if (!confirm('"' + name + '" 尚未保存,确定要关闭吗?')) return; - } - ML.tabs.splice(index, 1); - ML.mruTabStack = ML.mruTabStack.filter(function (id) { return id !== tabId; }); - if (tabId === ML.activeTabId) { - if (ML.tabs.length === 0) { - ML.activeTabId = null; - ML.editor.value = ''; - ML.preview.innerHTML = ''; - ML.lineNumbers.innerHTML = ''; - ML.lineCountCache = 0; - ML.statusSize.textContent = ''; - ML.statusSizeDivider.classList.remove('visible'); - ML.welcomeScreen.classList.remove('hidden'); - document.title = 'MarkLite'; - ML.statusText.textContent = '就绪'; - if (typeof window.electronAPI !== 'undefined') window.electronAPI.tabSwitched(null); - } else { - var newIndex = Math.min(index, ML.tabs.length - 1); - ML.activeTabId = ML.tabs[newIndex].id; - ML.loadTabState(ML.tabs[newIndex]); - } - } - ML.renderTabBar(); - ML.updateTitle(); - ML.updateSidebarHighlight(); - }; - - ML.renderTabBar = function () { - ML.tabList.innerHTML = ''; - ML.tabs.forEach(function (tab) { - var el = document.createElement('div'); - el.className = 'tab-item' + (tab.id === ML.activeTabId ? ' active' : '') + (tab.isModified ? ' modified' : ''); - el.dataset.tabId = tab.id; - var name = document.createElement('span'); - name.className = 'tab-name'; - name.textContent = tab.filePath ? ML.getFileName(tab.filePath) : '未命名'; - el.appendChild(name); - var close = document.createElement('button'); - close.className = 'tab-close'; - close.innerHTML = ''; - close.addEventListener('click', function (e) { e.stopPropagation(); ML.closeTab(tab.id); }); - el.appendChild(close); - el.addEventListener('click', function () { ML.switchToTab(tab.id); }); - ML.tabList.appendChild(el); - }); - }; - - ML.updateTitle = function () { - var tab = ML.getActiveTab(); - if (tab) { - var name = tab.filePath ? ML.getFileName(tab.filePath) : '未命名'; - var mod = tab.isModified ? ' *' : ''; - document.title = 'MarkLite - ' + name + mod; - ML.statusText.textContent = name; - } else { - document.title = 'MarkLite'; - ML.statusText.textContent = '就绪'; - } - }; -})();