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] = '