From e071a26db67362f2e8fe4ec94ff06de309f7b5a7 Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 21 May 2026 13:34:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20renderer.js=E6=8B=86=E5=88=86?= =?UTF-8?q?=E4=B8=BA6=E4=B8=AA=E6=A8=A1=E5=9D=97(core/tabs/editor/search/s?= =?UTF-8?q?idebar/markdown)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- renderer/core.js | 454 ++++++++++++ renderer/editor.js | 147 ++++ renderer/index.html | 7 +- renderer/markdown.js | 103 +++ renderer/renderer.js | 1663 ------------------------------------------ renderer/search.js | 158 ++++ renderer/sidebar.js | 208 ++++++ renderer/tabs.js | 156 ++++ 8 files changed, 1232 insertions(+), 1664 deletions(-) create mode 100644 renderer/core.js create mode 100644 renderer/editor.js create mode 100644 renderer/markdown.js delete mode 100644 renderer/renderer.js create mode 100644 renderer/search.js create mode 100644 renderer/sidebar.js create mode 100644 renderer/tabs.js diff --git a/renderer/core.js b/renderer/core.js new file mode 100644 index 0000000..1c177d4 --- /dev/null +++ b/renderer/core.js @@ -0,0 +1,454 @@ +// 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 new file mode 100644 index 0000000..fd3fa37 --- /dev/null +++ b/renderer/editor.js @@ -0,0 +1,147 @@ +// 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 9913871..fc63807 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -210,6 +210,11 @@ - + + + + + + diff --git a/renderer/markdown.js b/renderer/markdown.js new file mode 100644 index 0000000..7fd95e6 --- /dev/null +++ b/renderer/markdown.js @@ -0,0 +1,103 @@ +// 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 deleted file mode 100644 index b5e0590..0000000 --- a/renderer/renderer.js +++ /dev/null @@ -1,1663 +0,0 @@ -// 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 new file mode 100644 index 0000000..385365f --- /dev/null +++ b/renderer/search.js @@ -0,0 +1,158 @@ +// 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 new file mode 100644 index 0000000..4600043 --- /dev/null +++ b/renderer/sidebar.js @@ -0,0 +1,208 @@ +// 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 new file mode 100644 index 0000000..b75201b --- /dev/null +++ b/renderer/tabs.js @@ -0,0 +1,156 @@ +// 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 = '就绪'; + } + }; +})();