618 lines
18 KiB
JavaScript
618 lines
18 KiB
JavaScript
// 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');
|
|
|
|
// 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');
|
|
|
|
// ===== State =====
|
|
let currentFilePath = null;
|
|
let currentContent = '';
|
|
let viewMode = 'split';
|
|
let isModified = false;
|
|
let updateTimer = null;
|
|
let lineCountCache = 0;
|
|
|
|
// ===== localStorage Helpers =====
|
|
function loadSettings() {
|
|
try {
|
|
const s = JSON.parse(localStorage.getItem('marklite-settings') || '{}');
|
|
return s;
|
|
} 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';
|
|
}
|
|
|
|
apply(!!settings.darkMode);
|
|
|
|
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();
|
|
renderer.code = function ({ text, lang }) {
|
|
let highlighted = text;
|
|
if (typeof hljs !== 'undefined') {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* fallback */ }
|
|
} else {
|
|
try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* fallback */ }
|
|
}
|
|
}
|
|
const langClass = lang ? ` language-${lang}` : '';
|
|
return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
|
|
};
|
|
|
|
marked.setOptions({
|
|
gfm: true,
|
|
breaks: true,
|
|
pedantic: false
|
|
});
|
|
marked.use({ renderer });
|
|
}
|
|
}
|
|
|
|
// ===== Markdown Rendering =====
|
|
function renderMarkdown(text) {
|
|
if (typeof marked === 'undefined') {
|
|
preview.innerHTML = '<p style="color: red;">错误: Markdown 解析库未加载</p>';
|
|
return;
|
|
}
|
|
try {
|
|
const html = marked.parse(text);
|
|
preview.innerHTML = html;
|
|
} catch (e) {
|
|
preview.innerHTML = '<p style="color: red;">渲染错误: ' + e.message + '</p>';
|
|
}
|
|
}
|
|
|
|
// ===== Line Numbers (optimized: batch innerHTML) =====
|
|
function updateLineNumbers() {
|
|
const count = editor.value.split('\n').length;
|
|
if (count === lineCountCache) return;
|
|
lineCountCache = count;
|
|
|
|
const fragment = [];
|
|
for (let i = 1; i <= count; i++) {
|
|
fragment.push('<div class="line-num">', i, '</div>');
|
|
}
|
|
lineNumbers.innerHTML = fragment.join('');
|
|
}
|
|
|
|
// ===== Cursor Position =====
|
|
function updateCursorPosition() {
|
|
const text = editor.value;
|
|
const pos = editor.selectionStart;
|
|
const textBeforeCursor = text.substring(0, pos);
|
|
const lines = textBeforeCursor.split('\n');
|
|
const line = lines.length;
|
|
const col = lines[lines.length - 1].length + 1;
|
|
statusCursor.textContent = `行 ${line}, 列 ${col}`;
|
|
}
|
|
|
|
// ===== 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');
|
|
}
|
|
|
|
// ===== Improved Scroll Sync (DOM position mapping) =====
|
|
let previewElements = null;
|
|
let previewPositions = null;
|
|
|
|
function cachePreviewPositions() {
|
|
previewElements = preview.children;
|
|
previewPositions = [];
|
|
for (let i = 0; i < previewElements.length; i++) {
|
|
previewPositions.push(previewElements[i].offsetTop);
|
|
}
|
|
}
|
|
|
|
function syncScroll() {
|
|
if (viewMode !== 'split') return;
|
|
|
|
// Map editor scroll to preview using line-to-DOM correlation
|
|
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
|
|
const scrollTop = editor.scrollTop;
|
|
const topLine = Math.floor(scrollTop / lineHeight);
|
|
const totalLines = editor.value.split('\n').length;
|
|
|
|
if (!previewPositions || previewPositions.length === 0) {
|
|
cachePreviewPositions();
|
|
}
|
|
|
|
if (!previewPositions || previewPositions.length === 0) return;
|
|
|
|
// Find which preview element corresponds to the current editor line
|
|
// Use a rough heuristic: map line ratio to element ratio
|
|
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];
|
|
}
|
|
}
|
|
|
|
// ===== Update Preview =====
|
|
function scheduleUpdate() {
|
|
if (updateTimer) clearTimeout(updateTimer);
|
|
updateTimer = setTimeout(() => {
|
|
const text = editor.value;
|
|
renderMarkdown(text);
|
|
updateLineNumbers();
|
|
updateFileSize();
|
|
cachePreviewPositions();
|
|
}, 150);
|
|
}
|
|
|
|
// ===== File Operations =====
|
|
function setModified(modified) {
|
|
isModified = modified;
|
|
const title = currentFilePath
|
|
? `MarkLite - ${getFileName(currentFilePath)}${modified ? ' *' : ''}`
|
|
: `MarkLite - 未命名${modified ? ' *' : ''}`;
|
|
document.title = title;
|
|
}
|
|
|
|
function getFileName(filePath) {
|
|
return filePath.split(/[/\\]/).pop();
|
|
}
|
|
|
|
async function updateFileStats(filePath) {
|
|
if (typeof window.electronAPI !== 'undefined' && filePath) {
|
|
const stats = await window.electronAPI.getFileStats(filePath);
|
|
if (stats.success) {
|
|
statusSize.textContent = formatBytes(stats.size);
|
|
statusSizeDivider.classList.add('visible');
|
|
}
|
|
}
|
|
}
|
|
|
|
function setEditorContent(text, filePath) {
|
|
editor.value = text;
|
|
currentContent = text;
|
|
currentFilePath = filePath || null;
|
|
isModified = false;
|
|
lineCountCache = 0;
|
|
updateLineNumbers();
|
|
renderMarkdown(text);
|
|
updateCursorPosition();
|
|
updateFileSize();
|
|
cachePreviewPositions();
|
|
|
|
if (filePath) {
|
|
document.title = `MarkLite - ${getFileName(filePath)}`;
|
|
statusText.textContent = getFileName(filePath);
|
|
updateFileStats(filePath);
|
|
} else {
|
|
document.title = 'MarkLite - 未命名';
|
|
statusText.textContent = '未命名';
|
|
statusSize.textContent = '';
|
|
statusSizeDivider.classList.remove('visible');
|
|
}
|
|
|
|
welcomeScreen.classList.add('hidden');
|
|
}
|
|
|
|
async function handleOpenFile() {
|
|
if (typeof window.electronAPI !== 'undefined') {
|
|
const result = await window.electronAPI.openFile();
|
|
if (result && !result.error) {
|
|
setEditorContent(result.content, result.filePath);
|
|
}
|
|
} else {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.md,.markdown,.txt';
|
|
input.onchange = (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
setEditorContent(ev.target.result, file.name);
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (typeof window.electronAPI !== 'undefined') {
|
|
const result = await window.electronAPI.saveFile({
|
|
filePath: currentFilePath,
|
|
content: editor.value
|
|
});
|
|
if (result.success) {
|
|
currentFilePath = result.filePath;
|
|
currentContent = editor.value;
|
|
setModified(false);
|
|
statusText.textContent = '已保存';
|
|
setTimeout(() => {
|
|
statusText.textContent = getFileName(currentFilePath) || '就绪';
|
|
}, 2000);
|
|
}
|
|
} else {
|
|
const blob = new Blob([editor.value], { type: 'text/markdown' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (currentFilePath || 'untitled.md');
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
setModified(false);
|
|
}
|
|
}
|
|
|
|
async function handleSaveAs() {
|
|
if (typeof window.electronAPI !== 'undefined') {
|
|
const result = await window.electronAPI.saveFileAs({
|
|
content: editor.value
|
|
});
|
|
if (result.success) {
|
|
currentFilePath = result.filePath;
|
|
currentContent = editor.value;
|
|
setModified(false);
|
|
statusText.textContent = '已保存';
|
|
setTimeout(() => {
|
|
statusText.textContent = getFileName(currentFilePath) || '就绪';
|
|
}, 2000);
|
|
}
|
|
} else {
|
|
handleSave();
|
|
}
|
|
}
|
|
|
|
// ===== View Mode (with localStorage) =====
|
|
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 (with localStorage) =====
|
|
let isResizing = false;
|
|
|
|
function initResizer() {
|
|
// Restore saved ratio
|
|
const settings = loadSettings();
|
|
if (settings.splitRatio) {
|
|
const ratio = settings.splitRatio;
|
|
editorPanel.style.flex = `0 0 ${ratio}%`;
|
|
previewPanel.style.flex = `0 0 ${100 - ratio}%`;
|
|
}
|
|
|
|
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 containerRect = mainContent.getBoundingClientRect();
|
|
const percentage = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
|
const clamped = Math.max(20, Math.min(80, percentage));
|
|
editorPanel.style.flex = `0 0 ${clamped}%`;
|
|
previewPanel.style.flex = `0 0 ${100 - clamped}%`;
|
|
saveSetting('splitRatio', clamped);
|
|
});
|
|
|
|
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;
|
|
if (files.length > 0) {
|
|
const file = files[0];
|
|
const filePath = file.path;
|
|
|
|
if (filePath && typeof window.electronAPI !== 'undefined') {
|
|
const result = await window.electronAPI.readFile(filePath);
|
|
if (result.success) {
|
|
setEditorContent(result.content, filePath);
|
|
} else {
|
|
statusText.textContent = '打开失败: ' + result.error;
|
|
}
|
|
} else {
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
setEditorContent(ev.target.result, file.name);
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== Keyboard Shortcuts =====
|
|
function initKeyboard() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && e.key === 'o') {
|
|
e.preventDefault();
|
|
handleOpenFile();
|
|
}
|
|
if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSave();
|
|
}
|
|
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
|
|
e.preventDefault();
|
|
handleSaveAs();
|
|
}
|
|
if (e.ctrlKey && e.key === '1') {
|
|
e.preventDefault();
|
|
setViewMode('split');
|
|
}
|
|
if (e.ctrlKey && e.key === '2') {
|
|
e.preventDefault();
|
|
setViewMode('editor');
|
|
}
|
|
if (e.ctrlKey && e.key === '3') {
|
|
e.preventDefault();
|
|
setViewMode('preview');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== Tab key support =====
|
|
function initEditorTab() {
|
|
editor.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (!document.execCommand('insertText', false, ' ')) {
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
editor.setRangeText(' ', start, end, 'end');
|
|
}
|
|
scheduleUpdate();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== External File Modification =====
|
|
function initFileWatch() {
|
|
if (typeof window.electronAPI === 'undefined') return;
|
|
|
|
window.electronAPI.onExternalModification((filePath) => {
|
|
modifiedBanner.classList.remove('hidden');
|
|
});
|
|
|
|
btnReload.addEventListener('click', async () => {
|
|
const result = await window.electronAPI.reloadFile();
|
|
if (result.success) {
|
|
editor.value = result.content;
|
|
currentContent = result.content;
|
|
currentFilePath = result.filePath;
|
|
isModified = false;
|
|
lineCountCache = 0;
|
|
updateLineNumbers();
|
|
renderMarkdown(result.content);
|
|
updateCursorPosition();
|
|
updateFileSize();
|
|
cachePreviewPositions();
|
|
}
|
|
modifiedBanner.classList.add('hidden');
|
|
});
|
|
|
|
btnDismiss.addEventListener('click', () => {
|
|
modifiedBanner.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
// ===== Unsaved Changes Warning =====
|
|
function initUnsavedWarning() {
|
|
if (typeof window.electronAPI === 'undefined') {
|
|
// Browser fallback
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (isModified) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Electron: handle close confirmation from main process
|
|
window.electronAPI.onConfirmClose(() => {
|
|
if (!isModified) {
|
|
window.electronAPI.forceClose();
|
|
return;
|
|
}
|
|
|
|
// Show a simple confirm dialog
|
|
const shouldClose = confirm('文件尚未保存,确定要关闭吗?');
|
|
if (shouldClose) {
|
|
window.electronAPI.forceClose();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ===== Event Listeners =====
|
|
function initEventListeners() {
|
|
editor.addEventListener('input', () => {
|
|
setModified(true);
|
|
scheduleUpdate();
|
|
});
|
|
|
|
editor.addEventListener('scroll', syncScroll);
|
|
|
|
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'));
|
|
|
|
btnWelcomeOpen.addEventListener('click', handleOpenFile);
|
|
btnWelcomeNew.addEventListener('click', () => {
|
|
setEditorContent('', null);
|
|
});
|
|
|
|
if (typeof window.electronAPI !== 'undefined') {
|
|
window.electronAPI.onFileOpened((data) => {
|
|
setEditorContent(data.content, data.filePath);
|
|
});
|
|
|
|
window.electronAPI.onMenuSave(() => {
|
|
handleSave();
|
|
});
|
|
|
|
window.electronAPI.onMenuSaveAs(() => {
|
|
handleSaveAs();
|
|
});
|
|
|
|
window.electronAPI.onViewModeChange((mode) => {
|
|
setViewMode(mode);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ===== Initialize =====
|
|
function init() {
|
|
initMarked();
|
|
initDarkMode();
|
|
initResizer();
|
|
initDragDrop();
|
|
initKeyboard();
|
|
initEditorTab();
|
|
initEventListeners();
|
|
initFileWatch();
|
|
initUnsavedWarning();
|
|
updateLineNumbers();
|
|
|
|
// Restore saved view mode
|
|
const settings = loadSettings();
|
|
setViewMode(settings.viewMode || 'split');
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|