全面优化:暗色主题、文件监听、未保存提醒、滚动同步、分屏记忆、文件大小显示、删除死代码

This commit is contained in:
thzxx
2026-05-18 13:09:31 +08:00
parent dbbf1efc78
commit cb29c67f94
5 changed files with 403 additions and 157 deletions
+71 -96
View File
@@ -1,13 +1,14 @@
const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron'); const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
let mainWindow = null; let mainWindow = null;
let currentFilePath = null; let currentFilePath = null;
let pendingFilePath = null; // For file association race condition let pendingFilePath = null;
let fileWatcher = null;
let isModifiedExternally = false;
function createWindow() { function createWindow() {
// Create the browser window
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
@@ -23,7 +24,6 @@ function createWindow() {
show: false show: false
}); });
// Register did-finish-load BEFORE loadFile to avoid race condition
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
if (pendingFilePath) { if (pendingFilePath) {
openFile(pendingFilePath); openFile(pendingFilePath);
@@ -31,102 +31,48 @@ function createWindow() {
} }
}); });
// Load the index.html
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// Show window when ready
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow.show(); mainWindow.show();
}); });
// Remove native menu bar
mainWindow.setMenu(null); mainWindow.setMenu(null);
// Unsaved changes warning on close
mainWindow.on('close', (e) => {
mainWindow.webContents.send('window:closing');
e.preventDefault();
mainWindow.webContents.send('window:confirmClose');
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
stopWatching();
mainWindow = null; mainWindow = null;
}); });
} }
function buildMenu() { // File watcher - detect external modifications
const template = [ function startWatching(filePath) {
{ stopWatching();
label: '文件', try {
submenu: [ isModifiedExternally = false;
{ fileWatcher = fs.watch(filePath, (eventType) => {
label: '打开文件', if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) {
accelerator: 'CmdOrCtrl+O', isModifiedExternally = true;
click: () => handleOpenFile() mainWindow.webContents.send('file:externallyModified', filePath);
},
{
label: '保存',
accelerator: 'CmdOrCtrl+S',
click: () => mainWindow.webContents.send('menu:save')
},
{
label: '另存为',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => mainWindow.webContents.send('menu:saveAs')
},
{ type: 'separator' },
{
label: '退出',
accelerator: 'CmdOrCtrl+Q',
click: () => app.quit()
} }
]
},
{
label: '视图',
submenu: [
{
label: '编辑 + 预览',
accelerator: 'CmdOrCtrl+1',
click: () => mainWindow.webContents.send('menu:viewMode', 'split')
},
{
label: '纯编辑',
accelerator: 'CmdOrCtrl+2',
click: () => mainWindow.webContents.send('menu:viewMode', 'editor')
},
{
label: '纯预览',
accelerator: 'CmdOrCtrl+3',
click: () => mainWindow.webContents.send('menu:viewMode', 'preview')
},
{ type: 'separator' },
{
label: '开发者工具',
accelerator: 'F12',
click: () => mainWindow.webContents.toggleDevTools()
},
{ type: 'separator' },
{
label: '重新加载',
accelerator: 'CmdOrCtrl+R',
click: () => mainWindow.webContents.reload()
}
]
},
{
label: '帮助',
submenu: [
{
label: '关于 MarkLite',
click: () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '关于 MarkLite',
message: 'MarkLite v1.0.0',
detail: '一款轻量级的 Markdown 阅读器。\n\n技术栈:Electron + marked.js + highlight.js'
}); });
} catch (err) {
// Silently ignore watch errors
} }
} }
]
}
];
const menu = Menu.buildFromTemplate(template); function stopWatching() {
Menu.setApplicationMenu(menu); if (fileWatcher) {
fileWatcher.close();
fileWatcher = null;
}
} }
async function showOpenDialog() { async function showOpenDialog() {
@@ -143,17 +89,11 @@ async function showOpenDialog() {
return null; return null;
} }
async function handleOpenFile() {
const filePath = await showOpenDialog();
if (filePath) {
openFile(filePath);
}
}
function openFile(filePath) { function openFile(filePath) {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
currentFilePath = filePath; currentFilePath = filePath;
startWatching(filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
mainWindow.webContents.send('file:opened', { filePath, content }); mainWindow.webContents.send('file:opened', { filePath, content });
} catch (err) { } catch (err) {
@@ -163,14 +103,19 @@ function openFile(filePath) {
// IPC Handlers // IPC Handlers
ipcMain.handle('dialog:openFile', async () => { ipcMain.handle('dialog:openFile', async () => {
try {
const filePath = await showOpenDialog(); const filePath = await showOpenDialog();
if (filePath) { if (filePath) {
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
currentFilePath = filePath; currentFilePath = filePath;
startWatching(filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
return { filePath, content }; return { filePath, content };
} }
return null; return null;
} catch (err) {
return { error: err.message };
}
}); });
ipcMain.handle('file:read', async (event, filePath) => { ipcMain.handle('file:read', async (event, filePath) => {
@@ -185,12 +130,13 @@ ipcMain.handle('file:read', async (event, filePath) => {
ipcMain.handle('file:save', async (event, { filePath, content }) => { ipcMain.handle('file:save', async (event, { filePath, content }) => {
try { try {
if (filePath) { if (filePath) {
stopWatching();
fs.writeFileSync(filePath, content, 'utf-8'); fs.writeFileSync(filePath, content, 'utf-8');
currentFilePath = filePath; currentFilePath = filePath;
startWatching(filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
return { success: true, filePath }; return { success: true, filePath };
} else { } else {
// No file path, do save as
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
filters: [ filters: [
{ name: 'Markdown 文件', extensions: ['md'] }, { name: 'Markdown 文件', extensions: ['md'] },
@@ -198,8 +144,10 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => {
] ]
}); });
if (!result.canceled) { if (!result.canceled) {
stopWatching();
fs.writeFileSync(result.filePath, content, 'utf-8'); fs.writeFileSync(result.filePath, content, 'utf-8');
currentFilePath = result.filePath; currentFilePath = result.filePath;
startWatching(result.filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`);
return { success: true, filePath: result.filePath }; return { success: true, filePath: result.filePath };
} }
@@ -219,8 +167,10 @@ ipcMain.handle('file:saveAs', async (event, { content }) => {
] ]
}); });
if (!result.canceled) { if (!result.canceled) {
stopWatching();
fs.writeFileSync(result.filePath, content, 'utf-8'); fs.writeFileSync(result.filePath, content, 'utf-8');
currentFilePath = result.filePath; currentFilePath = result.filePath;
startWatching(result.filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`);
return { success: true, filePath: result.filePath }; return { success: true, filePath: result.filePath };
} }
@@ -234,6 +184,36 @@ ipcMain.handle('file:getCurrentPath', () => {
return currentFilePath; return currentFilePath;
}); });
ipcMain.handle('file:stats', async (event, filePath) => {
try {
const stats = fs.statSync(filePath);
return {
success: true,
size: stats.size,
mtime: stats.mtime.toISOString()
};
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('file:reload', async () => {
if (!currentFilePath) return { success: false, error: '没有打开的文件' };
try {
const content = fs.readFileSync(currentFilePath, 'utf-8');
isModifiedExternally = false;
return { success: true, content, filePath: currentFilePath };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.handle('window:forceClose', () => {
stopWatching();
mainWindow.removeAllListeners('close');
mainWindow.close();
});
// Handle file open from command line or file association // Handle file open from command line or file association
function getCommandLineFile() { function getCommandLineFile() {
const args = process.argv.slice(1); const args = process.argv.slice(1);
@@ -248,28 +228,23 @@ function getCommandLineFile() {
// App lifecycle // App lifecycle
app.whenReady().then(() => { app.whenReady().then(() => {
// Register open-file handler BEFORE creating window (fixes race condition)
app.on('open-file', (event, filePath) => { app.on('open-file', (event, filePath) => {
event.preventDefault(); event.preventDefault();
if (mainWindow && mainWindow.webContents.isLoading()) { if (mainWindow && mainWindow.webContents.isLoading()) {
// Window is still loading, queue the file
pendingFilePath = filePath; pendingFilePath = filePath;
} else if (mainWindow) { } else if (mainWindow) {
openFile(filePath); openFile(filePath);
} else { } else {
// Window not created yet, store for later
pendingFilePath = filePath; pendingFilePath = filePath;
} }
}); });
createWindow(); createWindow();
// Check for file passed via command line
const fileToOpen = getCommandLineFile(); const fileToOpen = getCommandLineFile();
if (fileToOpen) { if (fileToOpen) {
pendingFilePath = fileToOpen; pendingFilePath = fileToOpen;
} }
// pendingFilePath will be opened by the did-finish-load handler in createWindow()
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
+12
View File
@@ -7,6 +7,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (data) => ipcRenderer.invoke('file:save', data), saveFile: (data) => ipcRenderer.invoke('file:save', data),
saveFileAs: (data) => ipcRenderer.invoke('file:saveAs', data), saveFileAs: (data) => ipcRenderer.invoke('file:saveAs', data),
getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'), getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'),
getFileStats: (filePath) => ipcRenderer.invoke('file:stats', filePath),
reloadFile: () => ipcRenderer.invoke('file:reload'),
// Window control
forceClose: () => ipcRenderer.invoke('window:forceClose'),
// Menu events // Menu events
onFileOpened: (callback) => ipcRenderer.on('file:opened', (event, data) => callback(data)), onFileOpened: (callback) => ipcRenderer.on('file:opened', (event, data) => callback(data)),
@@ -14,6 +19,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
onMenuSaveAs: (callback) => ipcRenderer.on('menu:saveAs', () => callback()), onMenuSaveAs: (callback) => ipcRenderer.on('menu:saveAs', () => callback()),
onViewModeChange: (callback) => ipcRenderer.on('menu:viewMode', (event, mode) => callback(mode)), onViewModeChange: (callback) => ipcRenderer.on('menu:viewMode', (event, mode) => callback(mode)),
// File modification detection
onExternalModification: (callback) => ipcRenderer.on('file:externallyModified', (event, filePath) => callback(filePath)),
// Window close confirmation
onConfirmClose: (callback) => ipcRenderer.on('window:confirmClose', () => callback()),
onClosing: (callback) => ipcRenderer.on('window:closing', () => callback()),
// Remove listeners // Remove listeners
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel) removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
}); });
+28 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;">
<title>MarkLite</title> <title>MarkLite</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../lib/highlight-github.css"> <link rel="stylesheet" href="../lib/highlight-github.css">
@@ -50,6 +50,31 @@
<span>预览</span> <span>预览</span>
</button> </button>
</div> </div>
<div class="toolbar-right">
<button id="btn-dark" class="toolbar-btn" title="切换暗色主题">
<svg id="icon-dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="icon-light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</div>
<!-- External modification banner -->
<div id="modified-banner" class="hidden">
<span>文件已被外部程序修改</span>
<button id="btn-reload" class="banner-btn">重新加载</button>
<button id="btn-dismiss" class="banner-btn">忽略</button>
</div> </div>
<!-- Main content area --> <!-- Main content area -->
@@ -81,6 +106,8 @@
<span class="status-divider">|</span> <span class="status-divider">|</span>
<span id="status-lang">Markdown</span> <span id="status-lang">Markdown</span>
<span class="status-divider">|</span> <span class="status-divider">|</span>
<span id="status-size"></span>
<span class="status-divider status-size-divider">|</span>
<span id="status-cursor">行 1, 列 1</span> <span id="status-cursor">行 1, 列 1</span>
</div> </div>
</div> </div>
+200 -49
View File
@@ -8,8 +8,11 @@
const lineNumbers = document.getElementById('line-numbers'); const lineNumbers = document.getElementById('line-numbers');
const welcomeScreen = document.getElementById('welcome-screen'); const welcomeScreen = document.getElementById('welcome-screen');
const dropOverlay = document.getElementById('drop-overlay'); const dropOverlay = document.getElementById('drop-overlay');
const modifiedBanner = document.getElementById('modified-banner');
const statusText = document.getElementById('status-text'); const statusText = document.getElementById('status-text');
const statusCursor = document.getElementById('status-cursor'); 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 resizer = document.getElementById('resizer');
const editorPanel = document.getElementById('editor-panel'); const editorPanel = document.getElementById('editor-panel');
const previewPanel = document.getElementById('preview-panel'); const previewPanel = document.getElementById('preview-panel');
@@ -23,18 +26,58 @@
const btnPreview = document.getElementById('btn-preview'); const btnPreview = document.getElementById('btn-preview');
const btnWelcomeOpen = document.getElementById('btn-welcome-open'); const btnWelcomeOpen = document.getElementById('btn-welcome-open');
const btnWelcomeNew = document.getElementById('btn-welcome-new'); 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 ===== // ===== State =====
let currentFilePath = null; let currentFilePath = null;
let currentContent = ''; let currentContent = '';
let viewMode = 'split'; // split, editor, preview let viewMode = 'split';
let isModified = false; let isModified = false;
let updateTimer = null; 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 ===== // ===== Initialize marked.js =====
function initMarked() { function initMarked() {
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
// Custom renderer for code block highlighting (compatible with marked v12+)
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }) { renderer.code = function ({ text, lang }) {
let highlighted = text; let highlighted = text;
@@ -72,15 +115,17 @@
} }
} }
// ===== Line Numbers ===== // ===== Line Numbers (optimized: batch innerHTML) =====
function updateLineNumbers() { function updateLineNumbers() {
const lines = editor.value.split('\n'); const count = editor.value.split('\n').length;
const count = lines.length; if (count === lineCountCache) return;
let html = ''; lineCountCache = count;
const fragment = [];
for (let i = 1; i <= count; i++) { for (let i = 1; i <= count; i++) {
html += '<div class="line-num">' + i + '</div>'; fragment.push('<div class="line-num">', i, '</div>');
} }
lineNumbers.innerHTML = html; lineNumbers.innerHTML = fragment.join('');
} }
// ===== Cursor Position ===== // ===== Cursor Position =====
@@ -94,25 +139,55 @@
statusCursor.textContent = `${line}, 列 ${col}`; statusCursor.textContent = `${line}, 列 ${col}`;
} }
// ===== Sync Scroll (line-based for more accurate sync) ===== // ===== File Size =====
function updateFileSize() {
const bytes = new TextEncoder().encode(editor.value).length;
let display;
if (bytes < 1024) display = bytes + ' B';
else if (bytes < 1024 * 1024) display = (bytes / 1024).toFixed(1) + ' KB';
else display = (bytes / (1024 * 1024)).toFixed(1) + ' MB';
statusSize.textContent = display;
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() { function syncScroll() {
if (viewMode !== 'split') return; if (viewMode !== 'split') return;
// Calculate which line is at the top of the editor viewport // Map editor scroll to preview using line-to-DOM correlation
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const scrollTop = editor.scrollTop; const scrollTop = editor.scrollTop;
const topLine = Math.floor(scrollTop / lineHeight); const topLine = Math.floor(scrollTop / lineHeight);
// Calculate corresponding scroll position in preview
const previewChildren = preview.children;
if (!previewChildren.length) return;
// Find the element that corresponds to the current top line
// Approximate: map editor line ratio to preview element positions
const totalLines = editor.value.split('\n').length; const totalLines = editor.value.split('\n').length;
const ratio = totalLines > 0 ? topLine / totalLines : 0;
const targetScrollTop = ratio * (previewPanel.scrollHeight - previewPanel.clientHeight); if (!previewPositions || previewPositions.length === 0) {
previewPanel.scrollTop = targetScrollTop; 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 ===== // ===== Update Preview =====
@@ -122,6 +197,8 @@
const text = editor.value; const text = editor.value;
renderMarkdown(text); renderMarkdown(text);
updateLineNumbers(); updateLineNumbers();
updateFileSize();
cachePreviewPositions();
}, 150); }, 150);
} }
@@ -138,35 +215,55 @@
return filePath.split(/[/\\]/).pop(); return filePath.split(/[/\\]/).pop();
} }
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';
}
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) { function setEditorContent(text, filePath) {
editor.value = text; editor.value = text;
currentContent = text; currentContent = text;
currentFilePath = filePath || null; currentFilePath = filePath || null;
isModified = false; isModified = false;
lineCountCache = 0;
updateLineNumbers(); updateLineNumbers();
renderMarkdown(text); renderMarkdown(text);
updateCursorPosition(); updateCursorPosition();
updateFileSize();
cachePreviewPositions();
if (filePath) { if (filePath) {
document.title = `MarkLite - ${getFileName(filePath)}`; document.title = `MarkLite - ${getFileName(filePath)}`;
statusText.textContent = getFileName(filePath); statusText.textContent = getFileName(filePath);
updateFileStats(filePath);
} else { } else {
document.title = 'MarkLite - 未命名'; document.title = 'MarkLite - 未命名';
statusText.textContent = '未命名'; statusText.textContent = '未命名';
statusSize.textContent = '';
statusSizeDivider.classList.remove('visible');
} }
// Hide welcome screen
welcomeScreen.classList.add('hidden'); welcomeScreen.classList.add('hidden');
} }
async function handleOpenFile() { async function handleOpenFile() {
if (typeof window.electronAPI !== 'undefined') { if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.openFile(); const result = await window.electronAPI.openFile();
if (result) { if (result && !result.error) {
setEditorContent(result.content, result.filePath); setEditorContent(result.content, result.filePath);
} }
} else { } else {
// Fallback: use file input
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.md,.markdown,.txt'; input.accept = '.md,.markdown,.txt';
@@ -200,7 +297,6 @@
}, 2000); }, 2000);
} }
} else { } else {
// Fallback: download file
const blob = new Blob([editor.value], { type: 'text/markdown' }); const blob = new Blob([editor.value], { type: 'text/markdown' });
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
@@ -230,15 +326,13 @@
} }
} }
// ===== View Mode ===== // ===== View Mode (with localStorage) =====
function setViewMode(mode) { function setViewMode(mode) {
viewMode = mode; viewMode = mode;
const app = document.getElementById('app'); const app = document.getElementById('app');
// Remove all mode classes
app.classList.remove('mode-editor', 'mode-preview'); app.classList.remove('mode-editor', 'mode-preview');
// Update button states
btnSplit.classList.remove('active'); btnSplit.classList.remove('active');
btnEditor.classList.remove('active'); btnEditor.classList.remove('active');
btnPreview.classList.remove('active'); btnPreview.classList.remove('active');
@@ -254,16 +348,25 @@
case 'preview': case 'preview':
btnPreview.classList.add('active'); btnPreview.classList.add('active');
app.classList.add('mode-preview'); app.classList.add('mode-preview');
// Update preview when switching to preview mode
renderMarkdown(editor.value); renderMarkdown(editor.value);
break; break;
} }
saveSetting('viewMode', mode);
} }
// ===== Resizer ===== // ===== Resizer (with localStorage) =====
let isResizing = false; let isResizing = false;
function initResizer() { 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) => { resizer.addEventListener('mousedown', (e) => {
isResizing = true; isResizing = true;
resizer.classList.add('active'); resizer.classList.add('active');
@@ -279,6 +382,7 @@
const clamped = Math.max(20, Math.min(80, percentage)); const clamped = Math.max(20, Math.min(80, percentage));
editorPanel.style.flex = `0 0 ${clamped}%`; editorPanel.style.flex = `0 0 ${clamped}%`;
previewPanel.style.flex = `0 0 ${100 - clamped}%`; previewPanel.style.flex = `0 0 ${100 - clamped}%`;
saveSetting('splitRatio', clamped);
}); });
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
@@ -325,10 +429,9 @@
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length > 0) { if (files.length > 0) {
const file = files[0]; const file = files[0];
const filePath = file.path; // Electron provides the real file path const filePath = file.path;
if (filePath && typeof window.electronAPI !== 'undefined') { if (filePath && typeof window.electronAPI !== 'undefined') {
// Use IPC to read file through main process (tracks currentFilePath)
const result = await window.electronAPI.readFile(filePath); const result = await window.electronAPI.readFile(filePath);
if (result.success) { if (result.success) {
setEditorContent(result.content, filePath); setEditorContent(result.content, filePath);
@@ -336,7 +439,6 @@
statusText.textContent = '打开失败: ' + result.error; statusText.textContent = '打开失败: ' + result.error;
} }
} else { } else {
// Fallback: use FileReader (browser mode)
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = (ev) => {
setEditorContent(ev.target.result, file.name); setEditorContent(ev.target.result, file.name);
@@ -350,32 +452,26 @@
// ===== Keyboard Shortcuts ===== // ===== Keyboard Shortcuts =====
function initKeyboard() { function initKeyboard() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Ctrl+O: Open
if (e.ctrlKey && e.key === 'o') { if (e.ctrlKey && e.key === 'o') {
e.preventDefault(); e.preventDefault();
handleOpenFile(); handleOpenFile();
} }
// Ctrl+S: Save
if (e.ctrlKey && e.key === 's' && !e.shiftKey) { if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSave(); handleSave();
} }
// Ctrl+Shift+S: Save As
if (e.ctrlKey && e.shiftKey && e.key === 'S') { if (e.ctrlKey && e.shiftKey && e.key === 'S') {
e.preventDefault(); e.preventDefault();
handleSaveAs(); handleSaveAs();
} }
// Ctrl+1: Split view
if (e.ctrlKey && e.key === '1') { if (e.ctrlKey && e.key === '1') {
e.preventDefault(); e.preventDefault();
setViewMode('split'); setViewMode('split');
} }
// Ctrl+2: Editor only
if (e.ctrlKey && e.key === '2') { if (e.ctrlKey && e.key === '2') {
e.preventDefault(); e.preventDefault();
setViewMode('editor'); setViewMode('editor');
} }
// Ctrl+3: Preview only
if (e.ctrlKey && e.key === '3') { if (e.ctrlKey && e.key === '3') {
e.preventDefault(); e.preventDefault();
setViewMode('preview'); setViewMode('preview');
@@ -383,14 +479,12 @@
}); });
} }
// ===== Tab key support in editor (preserves undo history) ===== // ===== Tab key support =====
function initEditorTab() { function initEditorTab() {
editor.addEventListener('keydown', (e) => { editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
// Use insertText command to preserve undo/redo history
if (!document.execCommand('insertText', false, ' ')) { if (!document.execCommand('insertText', false, ' ')) {
// Fallback for environments where execCommand is unsupported
const start = editor.selectionStart; const start = editor.selectionStart;
const end = editor.selectionEnd; const end = editor.selectionEnd;
editor.setRangeText(' ', start, end, 'end'); editor.setRangeText(' ', start, end, 'end');
@@ -400,36 +494,88 @@
}); });
} }
// ===== 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 ===== // ===== Event Listeners =====
function initEventListeners() { function initEventListeners() {
// Editor input
editor.addEventListener('input', () => { editor.addEventListener('input', () => {
setModified(true); setModified(true);
scheduleUpdate(); scheduleUpdate();
}); });
// Editor scroll sync
editor.addEventListener('scroll', syncScroll); editor.addEventListener('scroll', syncScroll);
// Editor cursor position
editor.addEventListener('click', updateCursorPosition); editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition); editor.addEventListener('keyup', updateCursorPosition);
editor.addEventListener('select', updateCursorPosition); editor.addEventListener('select', updateCursorPosition);
// Toolbar buttons
btnOpen.addEventListener('click', handleOpenFile); btnOpen.addEventListener('click', handleOpenFile);
btnSave.addEventListener('click', handleSave); btnSave.addEventListener('click', handleSave);
btnSplit.addEventListener('click', () => setViewMode('split')); btnSplit.addEventListener('click', () => setViewMode('split'));
btnEditor.addEventListener('click', () => setViewMode('editor')); btnEditor.addEventListener('click', () => setViewMode('editor'));
btnPreview.addEventListener('click', () => setViewMode('preview')); btnPreview.addEventListener('click', () => setViewMode('preview'));
// Welcome buttons
btnWelcomeOpen.addEventListener('click', handleOpenFile); btnWelcomeOpen.addEventListener('click', handleOpenFile);
btnWelcomeNew.addEventListener('click', () => { btnWelcomeNew.addEventListener('click', () => {
setEditorContent('', null); setEditorContent('', null);
}); });
// Electron IPC events
if (typeof window.electronAPI !== 'undefined') { if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.onFileOpened((data) => { window.electronAPI.onFileOpened((data) => {
setEditorContent(data.content, data.filePath); setEditorContent(data.content, data.filePath);
@@ -452,16 +598,21 @@
// ===== Initialize ===== // ===== Initialize =====
function init() { function init() {
initMarked(); initMarked();
initDarkMode();
initResizer(); initResizer();
initDragDrop(); initDragDrop();
initKeyboard(); initKeyboard();
initEditorTab(); initEditorTab();
initEventListeners(); initEventListeners();
initFileWatch();
initUnsavedWarning();
updateLineNumbers(); updateLineNumbers();
setViewMode('split');
// Restore saved view mode
const settings = loadSettings();
setViewMode(settings.viewMode || 'split');
} }
// Run when DOM is ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
+82 -1
View File
@@ -27,6 +27,24 @@
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace; --font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
} }
/* ===== Dark Theme ===== */
:root.dark {
--primary: #8ab4f8;
--primary-light: #1a3a5c;
--primary-dark: #aecbfa;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d2d;
--text: #d4d4d4;
--text-secondary: #9e9e9e;
--text-tertiary: #6e6e6e;
--border: #3e3e3e;
--border-light: #333333;
--code-bg: #2d2d2d;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
}
html, body { html, body {
height: 100%; height: 100%;
font-family: var(--font-ui); font-family: var(--font-ui);
@@ -50,12 +68,14 @@ html, body {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0 8px; padding: 0 8px;
flex-shrink: 0; flex-shrink: 0;
z-index: 10; z-index: 10;
} }
.toolbar-left { .toolbar-left,
.toolbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
@@ -98,6 +118,55 @@ html, body {
margin: 0 6px; margin: 0 6px;
} }
/* ===== Modified Banner ===== */
#modified-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
font-size: 13px;
color: #856404;
flex-shrink: 0;
}
:root.dark #modified-banner {
background: #3a3000;
border-bottom-color: #665500;
color: #ffd54f;
}
#modified-banner.hidden {
display: none;
}
.banner-btn {
padding: 2px 10px;
border: 1px solid #ffc107;
border-radius: 4px;
background: transparent;
color: #856404;
font-size: 12px;
cursor: pointer;
font-family: var(--font-ui);
}
:root.dark .banner-btn {
border-color: #665500;
color: #ffd54f;
}
.banner-btn:hover {
background: #ffc107;
color: #000;
}
:root.dark .banner-btn:hover {
background: #665500;
color: #fff;
}
/* ===== Main Content ===== */ /* ===== Main Content ===== */
#main-content { #main-content {
flex: 1; flex: 1;
@@ -298,6 +367,10 @@ html, body {
color: #e83e8c; color: #e83e8c;
} }
:root.dark .markdown-body code {
color: #f48fb1;
}
.markdown-body pre { .markdown-body pre {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
@@ -370,6 +443,14 @@ html, body {
color: var(--border); color: var(--border);
} }
.status-size-divider {
display: none;
}
.status-size-divider.visible {
display: inline;
}
/* ===== Drop Overlay ===== */ /* ===== Drop Overlay ===== */
#drop-overlay { #drop-overlay {
position: fixed; position: fixed;