455 lines
19 KiB
JavaScript
455 lines
19 KiB
JavaScript
// 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();
|
|
}
|
|
})();
|