新增多标签页支持:Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab 切换、拖拽多文件打开

This commit is contained in:
thzxx
2026-05-18 13:28:45 +08:00
parent 0d82d4ff5e
commit 78f787a2db
5 changed files with 520 additions and 232 deletions
+79 -45
View File
@@ -3,7 +3,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
let mainWindow = null; let mainWindow = null;
let currentFilePath = null; let activeFilePath = null; // Currently active tab's file path
let pendingFilePath = null; let pendingFilePath = null;
let fileWatcher = null; let fileWatcher = null;
@@ -25,7 +25,7 @@ function createWindow() {
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
if (pendingFilePath) { if (pendingFilePath) {
openFile(pendingFilePath); openFileInTab(pendingFilePath);
pendingFilePath = null; pendingFilePath = null;
} }
}); });
@@ -38,18 +38,16 @@ function createWindow() {
mainWindow.setMenu(null); mainWindow.setMenu(null);
// Unsaved changes warning on close (with timeout fallback) // Unsaved changes warning on close
mainWindow.on('close', (e) => { mainWindow.on('close', (e) => {
e.preventDefault(); e.preventDefault();
try { try {
mainWindow.webContents.send('window:confirmClose'); mainWindow.webContents.send('window:confirmClose');
} catch (err) { } catch (err) {
// Renderer is gone, force close
mainWindow.removeAllListeners('close'); mainWindow.removeAllListeners('close');
mainWindow.close(); mainWindow.close();
return; return;
} }
// Safety: if renderer doesn't respond within 5s, force close
setTimeout(() => { setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.removeAllListeners('close'); mainWindow.removeAllListeners('close');
@@ -64,9 +62,10 @@ function createWindow() {
}); });
} }
// File watcher - detect external modifications // File watcher
function startWatching(filePath) { function startWatching(filePath) {
stopWatching(); stopWatching();
if (!filePath) return;
try { try {
fileWatcher = fs.watch(filePath, (eventType) => { fileWatcher = fs.watch(filePath, (eventType) => {
if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) { if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) {
@@ -74,7 +73,7 @@ function startWatching(filePath) {
} }
}); });
} catch (err) { } catch (err) {
// Silently ignore watch errors // Silently ignore
} }
} }
@@ -85,42 +84,82 @@ function stopWatching() {
} }
} }
async function showOpenDialog() { async function showOpenDialog(multi) {
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'], properties: multi ? ['openFile', 'multiSelections'] : ['openFile'],
filters: [ filters: [
{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }, { name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] },
{ name: '所有文件', extensions: ['*'] } { name: '所有文件', extensions: ['*'] }
] ]
}); });
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]; return result.filePaths;
} }
return null; return null;
} }
function openFile(filePath) { function readFileContent(filePath) {
return fs.readFileSync(filePath, 'utf-8');
}
// Open file in a new tab (renderer manages tabs)
function openFileInTab(filePath) {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = readFileContent(filePath);
currentFilePath = filePath; activeFilePath = filePath;
startWatching(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:openInTab', { filePath, content });
} catch (err) { } catch (err) {
dialog.showErrorBox('错误', `无法读取文件: ${err.message}`); dialog.showErrorBox('错误', `无法读取文件: ${err.message}`);
} }
} }
// Switch active file (for tab switching)
function switchActiveFile(filePath) {
activeFilePath = filePath || null;
startWatching(filePath);
if (filePath) {
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
} else {
mainWindow.setTitle('MarkLite');
}
}
// IPC Handlers // IPC Handlers
// Open single file via dialog
ipcMain.handle('dialog:openFile', async () => { ipcMain.handle('dialog:openFile', async () => {
try { try {
const filePath = await showOpenDialog(); const files = await showOpenDialog(false);
if (filePath) { if (files && files.length > 0) {
const content = fs.readFileSync(filePath, 'utf-8'); const content = readFileContent(files[0]);
currentFilePath = filePath; activeFilePath = files[0];
startWatching(filePath); startWatching(files[0]);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(files[0])}`);
return { filePath, content }; return { filePath: files[0], content };
}
return null;
} catch (err) {
return { error: err.message };
}
});
// Open multiple files via dialog
ipcMain.handle('dialog:openFiles', async () => {
try {
const files = await showOpenDialog(true);
if (files && files.length > 0) {
const results = [];
for (const fp of files) {
try {
const content = readFileContent(fp);
results.push({ filePath: fp, content });
} catch (err) {
results.push({ filePath: fp, error: err.message });
}
}
return results;
} }
return null; return null;
} catch (err) { } catch (err) {
@@ -130,7 +169,7 @@ ipcMain.handle('dialog:openFile', async () => {
ipcMain.handle('file:read', async (event, filePath) => { ipcMain.handle('file:read', async (event, filePath) => {
try { try {
const content = fs.readFileSync(filePath, 'utf-8'); const content = readFileContent(filePath);
return { success: true, content }; return { success: true, content };
} catch (err) { } catch (err) {
return { success: false, error: err.message }; return { success: false, error: err.message };
@@ -142,7 +181,7 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => {
if (filePath) { if (filePath) {
stopWatching(); stopWatching();
fs.writeFileSync(filePath, content, 'utf-8'); fs.writeFileSync(filePath, content, 'utf-8');
currentFilePath = filePath; activeFilePath = filePath;
startWatching(filePath); startWatching(filePath);
mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`);
return { success: true, filePath }; return { success: true, filePath };
@@ -156,7 +195,7 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => {
if (!result.canceled) { if (!result.canceled) {
stopWatching(); stopWatching();
fs.writeFileSync(result.filePath, content, 'utf-8'); fs.writeFileSync(result.filePath, content, 'utf-8');
currentFilePath = result.filePath; activeFilePath = result.filePath;
startWatching(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 };
@@ -179,7 +218,7 @@ ipcMain.handle('file:saveAs', async (event, { content }) => {
if (!result.canceled) { if (!result.canceled) {
stopWatching(); stopWatching();
fs.writeFileSync(result.filePath, content, 'utf-8'); fs.writeFileSync(result.filePath, content, 'utf-8');
currentFilePath = result.filePath; activeFilePath = result.filePath;
startWatching(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 };
@@ -191,46 +230,45 @@ ipcMain.handle('file:saveAs', async (event, { content }) => {
}); });
ipcMain.handle('file:getCurrentPath', () => { ipcMain.handle('file:getCurrentPath', () => {
return currentFilePath; return activeFilePath;
}); });
ipcMain.handle('file:stats', async (event, filePath) => { ipcMain.handle('file:stats', async (event, filePath) => {
try { try {
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
return { return { success: true, size: stats.size, mtime: stats.mtime.toISOString() };
success: true,
size: stats.size,
mtime: stats.mtime.toISOString()
};
} catch (err) { } catch (err) {
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
}); });
ipcMain.handle('file:reload', async () => { ipcMain.handle('file:reload', async () => {
if (!currentFilePath) return { success: false, error: '没有打开的文件' }; if (!activeFilePath) return { success: false, error: '没有打开的文件' };
try { try {
const content = fs.readFileSync(currentFilePath, 'utf-8'); const content = readFileContent(activeFilePath);
return { success: true, content, filePath: currentFilePath }; return { success: true, content, filePath: activeFilePath };
} catch (err) { } catch (err) {
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
}); });
// Tab switched - update active file tracking
ipcMain.handle('tab:switched', (event, filePath) => {
switchActiveFile(filePath);
});
ipcMain.handle('window:forceClose', () => { ipcMain.handle('window:forceClose', () => {
stopWatching(); stopWatching();
mainWindow.removeAllListeners('close'); mainWindow.removeAllListeners('close');
mainWindow.close(); mainWindow.close();
}); });
// Handle file open from command line or file association // Command line file
function getCommandLineFile() { function getCommandLineFile() {
const args = process.argv.slice(1); const args = process.argv.slice(1);
if (args.length > 0 && !args[0].startsWith('--')) { if (args.length > 0 && !args[0].startsWith('--')) {
const filePath = args[0]; const filePath = args[0];
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) return filePath;
return filePath;
}
} }
return null; return null;
} }
@@ -242,7 +280,7 @@ app.whenReady().then(() => {
if (mainWindow && mainWindow.webContents.isLoading()) { if (mainWindow && mainWindow.webContents.isLoading()) {
pendingFilePath = filePath; pendingFilePath = filePath;
} else if (mainWindow) { } else if (mainWindow) {
openFile(filePath); openFileInTab(filePath);
} else { } else {
pendingFilePath = filePath; pendingFilePath = filePath;
} }
@@ -257,13 +295,9 @@ app.whenReady().then(() => {
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') app.quit();
app.quit();
}
}); });
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) createWindow();
createWindow();
}
}); });
+6 -5
View File
@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// File operations // File operations
openFile: () => ipcRenderer.invoke('dialog:openFile'), openFile: () => ipcRenderer.invoke('dialog:openFile'),
openFiles: () => ipcRenderer.invoke('dialog:openFiles'),
readFile: (filePath) => ipcRenderer.invoke('file:read', filePath), readFile: (filePath) => ipcRenderer.invoke('file:read', filePath),
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),
@@ -10,19 +11,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
getFileStats: (filePath) => ipcRenderer.invoke('file:stats', filePath), getFileStats: (filePath) => ipcRenderer.invoke('file:stats', filePath),
reloadFile: () => ipcRenderer.invoke('file:reload'), reloadFile: () => ipcRenderer.invoke('file:reload'),
// Tab management
tabSwitched: (filePath) => ipcRenderer.invoke('tab:switched', filePath),
// Window control // Window control
forceClose: () => ipcRenderer.invoke('window:forceClose'), forceClose: () => ipcRenderer.invoke('window:forceClose'),
// Menu events // Events from main process
onFileOpenInTab: (callback) => ipcRenderer.on('file:openInTab', (event, data) => callback(data)),
onFileOpened: (callback) => ipcRenderer.on('file:opened', (event, data) => callback(data)), onFileOpened: (callback) => ipcRenderer.on('file:opened', (event, data) => callback(data)),
onMenuSave: (callback) => ipcRenderer.on('menu:save', () => callback()), onMenuSave: (callback) => ipcRenderer.on('menu:save', () => callback()),
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)), onExternalModification: (callback) => ipcRenderer.on('file:externallyModified', (event, filePath) => callback(filePath)),
// Window close confirmation
onConfirmClose: (callback) => ipcRenderer.on('window:confirmClose', () => callback()), onConfirmClose: (callback) => ipcRenderer.on('window:confirmClose', () => callback()),
onClosing: (callback) => ipcRenderer.on('window:closing', () => callback()), onClosing: (callback) => ipcRenderer.on('window:closing', () => callback()),
+12 -1
View File
@@ -70,6 +70,17 @@
</div> </div>
</div> </div>
<!-- Tab bar -->
<div id="tab-bar">
<div id="tab-list"></div>
<button id="btn-new-tab" class="tab-add-btn" title="新建标签页 (Ctrl+T)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<!-- External modification banner --> <!-- External modification banner -->
<div id="modified-banner" class="hidden"> <div id="modified-banner" class="hidden">
<span>文件已被外部程序修改</span> <span>文件已被外部程序修改</span>
@@ -151,7 +162,7 @@
</div> </div>
<div class="welcome-tips"> <div class="welcome-tips">
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p> <p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+1/2/3 切换视图</p> <p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+1/2/3 切换视图 | Ctrl+T 新建标签</p>
</div> </div>
</div> </div>
</div> </div>
+301 -181
View File
@@ -17,6 +17,7 @@
const editorPanel = document.getElementById('editor-panel'); const editorPanel = document.getElementById('editor-panel');
const previewPanel = document.getElementById('preview-panel'); const previewPanel = document.getElementById('preview-panel');
const mainContent = document.getElementById('main-content'); const mainContent = document.getElementById('main-content');
const tabList = document.getElementById('tab-list');
// Buttons // Buttons
const btnOpen = document.getElementById('btn-open'); const btnOpen = document.getElementById('btn-open');
@@ -29,20 +30,38 @@
const btnDark = document.getElementById('btn-dark'); const btnDark = document.getElementById('btn-dark');
const btnReload = document.getElementById('btn-reload'); const btnReload = document.getElementById('btn-reload');
const btnDismiss = document.getElementById('btn-dismiss'); const btnDismiss = document.getElementById('btn-dismiss');
const btnNewTab = document.getElementById('btn-new-tab');
// ===== State ===== // ===== Tab State =====
let currentFilePath = null; let tabs = []; // Array of tab objects
let currentContent = ''; let activeTabId = null;
let tabIdCounter = 0;
// ===== Other State =====
let viewMode = 'split'; let viewMode = 'split';
let isModified = false;
let updateTimer = null; let updateTimer = null;
let lineCountCache = 0; let lineCountCache = 0;
// ===== Tab Object =====
function createTab(filePath, content) {
const id = ++tabIdCounter;
return {
id,
filePath: filePath || null,
content: content || '',
isModified: false,
scrollTop: 0,
scrollLeft: 0,
selectionStart: 0,
selectionEnd: 0,
previewScrollTop: 0
};
}
// ===== localStorage Helpers ===== // ===== localStorage Helpers =====
function loadSettings() { function loadSettings() {
try { try {
const s = JSON.parse(localStorage.getItem('marklite-settings') || '{}'); return JSON.parse(localStorage.getItem('marklite-settings') || '{}');
return s;
} catch { return {}; } } catch { return {}; }
} }
@@ -91,12 +110,7 @@
const langClass = lang ? ` language-${lang}` : ''; const langClass = lang ? ` language-${lang}` : '';
return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`; return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
}; };
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
marked.setOptions({
gfm: true,
breaks: true,
pedantic: false
});
marked.use({ renderer }); marked.use({ renderer });
} }
} }
@@ -108,19 +122,17 @@
return; return;
} }
try { try {
const html = marked.parse(text); preview.innerHTML = marked.parse(text);
preview.innerHTML = html;
} catch (e) { } catch (e) {
preview.innerHTML = '<p style="color: red;">渲染错误: ' + e.message + '</p>'; preview.innerHTML = '<p style="color: red;">渲染错误: ' + e.message + '</p>';
} }
} }
// ===== Line Numbers (optimized: batch innerHTML) ===== // ===== Line Numbers =====
function updateLineNumbers() { function updateLineNumbers() {
const count = editor.value.split('\n').length; const count = editor.value.split('\n').length;
if (count === lineCountCache) return; if (count === lineCountCache) return;
lineCountCache = count; lineCountCache = count;
const fragment = []; const fragment = [];
for (let i = 1; i <= count; i++) { for (let i = 1; i <= count; i++) {
fragment.push('<div class="line-num">', i, '</div>'); fragment.push('<div class="line-num">', i, '</div>');
@@ -132,11 +144,9 @@
function updateCursorPosition() { function updateCursorPosition() {
const text = editor.value; const text = editor.value;
const pos = editor.selectionStart; const pos = editor.selectionStart;
const textBeforeCursor = text.substring(0, pos); const textBefore = text.substring(0, pos);
const lines = textBeforeCursor.split('\n'); const lines = textBefore.split('\n');
const line = lines.length; statusCursor.textContent = `${lines.length}, 列 ${lines[lines.length - 1].length + 1}`;
const col = lines[lines.length - 1].length + 1;
statusCursor.textContent = `${line}, 列 ${col}`;
} }
// ===== File Size ===== // ===== File Size =====
@@ -152,183 +162,307 @@
statusSizeDivider.classList.add('visible'); statusSizeDivider.classList.add('visible');
} }
// ===== Improved Scroll Sync (DOM position mapping) ===== // ===== Scroll Sync =====
let previewElements = null;
let previewPositions = null; let previewPositions = null;
function cachePreviewPositions() { function cachePreviewPositions() {
previewElements = preview.children;
previewPositions = []; previewPositions = [];
for (let i = 0; i < previewElements.length; i++) { for (let i = 0; i < preview.children.length; i++) {
previewPositions.push(previewElements[i].offsetTop); previewPositions.push(preview.children[i].offsetTop);
} }
} }
function syncScroll() { function syncScroll() {
if (viewMode !== 'split') return; if (viewMode !== 'split') return;
// 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);
const totalLines = editor.value.split('\n').length; const totalLines = editor.value.split('\n').length;
if (!previewPositions || previewPositions.length === 0) { if (!previewPositions || previewPositions.length === 0) cachePreviewPositions();
cachePreviewPositions();
}
if (!previewPositions || previewPositions.length === 0) return; 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 ratio = totalLines > 1 ? topLine / (totalLines - 1) : 0;
const targetIndex = Math.min( const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1);
Math.floor(ratio * previewPositions.length),
previewPositions.length - 1
);
if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) { if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) {
previewPanel.scrollTop = previewPositions[targetIndex]; previewPanel.scrollTop = previewPositions[targetIndex];
} }
} }
// ===== Update Preview ===== // ===== Schedule Update =====
function scheduleUpdate() { function scheduleUpdate() {
if (updateTimer) clearTimeout(updateTimer); if (updateTimer) clearTimeout(updateTimer);
updateTimer = setTimeout(() => { updateTimer = setTimeout(() => {
const text = editor.value; renderMarkdown(editor.value);
renderMarkdown(text);
updateLineNumbers(); updateLineNumbers();
updateFileSize(); updateFileSize();
cachePreviewPositions(); cachePreviewPositions();
}, 150); }, 150);
} }
// ===== File Operations ===== // ===== Helper =====
function setModified(modified) {
isModified = modified;
const title = currentFilePath
? `MarkLite - ${getFileName(currentFilePath)}${modified ? ' *' : ''}`
: `MarkLite - 未命名${modified ? ' *' : ''}`;
document.title = title;
}
function getFileName(filePath) { function getFileName(filePath) {
return filePath.split(/[/\\]/).pop(); return filePath.split(/[/\\]/).pop();
} }
async function updateFileStats(filePath) { // ===== Tab UI =====
if (typeof window.electronAPI !== 'undefined' && filePath) { function renderTabBar() {
const stats = await window.electronAPI.getFileStats(filePath); tabList.innerHTML = '';
if (stats.success) { for (const tab of tabs) {
statusSize.textContent = formatBytes(stats.size); const el = document.createElement('div');
statusSizeDivider.classList.add('visible'); el.className = 'tab-item' + (tab.id === activeTabId ? ' active' : '') + (tab.isModified ? ' modified' : '');
} el.dataset.tabId = tab.id;
const name = document.createElement('span');
name.className = 'tab-name';
name.textContent = tab.filePath ? getFileName(tab.filePath) : '未命名';
el.appendChild(name);
const close = document.createElement('button');
close.className = 'tab-close';
close.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
close.addEventListener('click', (e) => {
e.stopPropagation();
closeTab(tab.id);
});
el.appendChild(close);
el.addEventListener('click', () => switchToTab(tab.id));
tabList.appendChild(el);
} }
} }
function setEditorContent(text, filePath) { function updateTitle() {
editor.value = text; const tab = getActiveTab();
currentContent = text; if (tab) {
currentFilePath = filePath || null; const name = tab.filePath ? getFileName(tab.filePath) : '未命名';
isModified = false; const mod = tab.isModified ? ' *' : '';
document.title = `MarkLite - ${name}${mod}`;
statusText.textContent = name;
} else {
document.title = 'MarkLite';
statusText.textContent = '就绪';
}
}
// ===== Tab Operations =====
function getActiveTab() {
return tabs.find(t => t.id === activeTabId) || null;
}
function getTabIndex(tabId) {
return tabs.findIndex(t => t.id === tabId);
}
// Save current editor state into the active tab
function saveCurrentTabState() {
const tab = getActiveTab();
if (!tab) return;
tab.content = editor.value;
tab.scrollTop = editor.scrollTop;
tab.scrollLeft = editor.scrollLeft;
tab.selectionStart = editor.selectionStart;
tab.selectionEnd = editor.selectionEnd;
tab.previewScrollTop = previewPanel.scrollTop;
}
// Load a tab's state into the editor
function loadTabState(tab) {
editor.value = tab.content;
lineCountCache = 0; lineCountCache = 0;
updateLineNumbers(); updateLineNumbers();
renderMarkdown(text); renderMarkdown(tab.content);
updateCursorPosition();
updateFileSize(); updateFileSize();
cachePreviewPositions(); cachePreviewPositions();
// Restore scroll/selection on next frame
requestAnimationFrame(() => {
editor.scrollTop = tab.scrollTop;
editor.scrollLeft = tab.scrollLeft;
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
previewPanel.scrollTop = tab.previewScrollTop;
updateCursorPosition();
});
// Update modified state
setModified(tab.isModified);
// Notify main process about active file
if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.tabSwitched(tab.filePath);
}
}
function setModified(modified) {
const tab = getActiveTab();
if (tab) {
tab.isModified = modified;
renderTabBar();
}
updateTitle();
}
// Create a new tab and switch to it
function createNewTab(filePath, content) {
// Check if file is already open
if (filePath) { if (filePath) {
document.title = `MarkLite - ${getFileName(filePath)}`; const existing = tabs.find(t => t.filePath === filePath);
statusText.textContent = getFileName(filePath); if (existing) {
updateFileStats(filePath); switchToTab(existing.id);
} else { return existing;
document.title = 'MarkLite - 未命名'; }
statusText.textContent = '未命名';
statusSize.textContent = '';
statusSizeDivider.classList.remove('visible');
} }
const tab = createTab(filePath, content);
tabs.push(tab);
switchToTab(tab.id);
return tab;
}
// Switch to a specific tab
function switchToTab(tabId) {
if (tabId === activeTabId) return;
// Save current state
saveCurrentTabState();
// Switch
activeTabId = tabId;
const tab = getActiveTab();
if (!tab) return;
loadTabState(tab);
renderTabBar();
updateTitle();
welcomeScreen.classList.add('hidden'); welcomeScreen.classList.add('hidden');
} }
// Close a tab
function closeTab(tabId) {
const index = getTabIndex(tabId);
if (index === -1) return;
const tab = tabs[index];
// If modified, confirm
if (tab.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名';
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return;
}
// Remove tab
tabs.splice(index, 1);
// If we closed the active tab, switch to another
if (tabId === activeTabId) {
if (tabs.length === 0) {
// No tabs left - show welcome screen
activeTabId = null;
editor.value = '';
preview.innerHTML = '';
lineNumbers.innerHTML = '';
lineCountCache = 0;
statusSize.textContent = '';
statusSizeDivider.classList.remove('visible');
welcomeScreen.classList.remove('hidden');
document.title = 'MarkLite';
statusText.textContent = '就绪';
if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.tabSwitched(null);
}
} else {
// Switch to nearest tab
const newIndex = Math.min(index, tabs.length - 1);
activeTabId = tabs[newIndex].id;
loadTabState(tabs[newIndex]);
}
}
renderTabBar();
updateTitle();
}
// ===== File Operations =====
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 && !result.error) { if (result && !result.error) {
setEditorContent(result.content, result.filePath); createNewTab(result.filePath, result.content);
} }
} else { } else {
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';
input.multiple = true;
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0]; Array.from(e.target.files).forEach(file => {
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = (ev) => {
setEditorContent(ev.target.result, file.name); createNewTab(file.name, ev.target.result);
}; };
reader.readAsText(file); reader.readAsText(file);
} });
}; };
input.click(); input.click();
} }
} }
async function handleSave() { async function handleSave() {
const tab = getActiveTab();
if (!tab) return;
if (typeof window.electronAPI !== 'undefined') { if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.saveFile({ const result = await window.electronAPI.saveFile({
filePath: currentFilePath, filePath: tab.filePath,
content: editor.value content: editor.value
}); });
if (result.success) { if (result.success) {
currentFilePath = result.filePath; tab.filePath = result.filePath;
currentContent = editor.value; tab.content = editor.value;
setModified(false); tab.isModified = false;
renderTabBar();
updateTitle();
statusText.textContent = '已保存'; statusText.textContent = '已保存';
setTimeout(() => { setTimeout(() => updateTitle(), 2000);
statusText.textContent = getFileName(currentFilePath) || '就绪';
}, 2000);
} }
} else { } else {
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);
a.download = (currentFilePath || 'untitled.md'); a.download = tab.filePath || 'untitled.md';
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
setModified(false); tab.isModified = false;
renderTabBar();
updateTitle();
} }
} }
async function handleSaveAs() { async function handleSaveAs() {
const tab = getActiveTab();
if (!tab) return;
if (typeof window.electronAPI !== 'undefined') { if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.saveFileAs({ const result = await window.electronAPI.saveFileAs({ content: editor.value });
content: editor.value
});
if (result.success) { if (result.success) {
currentFilePath = result.filePath; tab.filePath = result.filePath;
currentContent = editor.value; tab.content = editor.value;
setModified(false); tab.isModified = false;
renderTabBar();
updateTitle();
statusText.textContent = '已保存'; statusText.textContent = '已保存';
setTimeout(() => { setTimeout(() => updateTitle(), 2000);
statusText.textContent = getFileName(currentFilePath) || '就绪';
}, 2000);
} }
} else { } else {
handleSave(); handleSave();
} }
} }
// ===== View Mode (with localStorage) ===== // ===== View Mode =====
function setViewMode(mode) { function setViewMode(mode) {
viewMode = mode; viewMode = mode;
const app = document.getElementById('app'); const app = document.getElementById('app');
app.classList.remove('mode-editor', 'mode-preview'); app.classList.remove('mode-editor', 'mode-preview');
btnSplit.classList.remove('active'); btnSplit.classList.remove('active');
btnEditor.classList.remove('active'); btnEditor.classList.remove('active');
btnPreview.classList.remove('active'); btnPreview.classList.remove('active');
@@ -347,20 +481,17 @@
renderMarkdown(editor.value); renderMarkdown(editor.value);
break; break;
} }
saveSetting('viewMode', mode); saveSetting('viewMode', mode);
} }
// ===== Resizer (with localStorage) ===== // ===== Resizer =====
let isResizing = false; let isResizing = false;
function initResizer() { function initResizer() {
// Restore saved ratio
const settings = loadSettings(); const settings = loadSettings();
if (settings.splitRatio) { if (settings.splitRatio) {
const ratio = settings.splitRatio; editorPanel.style.flex = `0 0 ${settings.splitRatio}%`;
editorPanel.style.flex = `0 0 ${ratio}%`; previewPanel.style.flex = `0 0 ${100 - settings.splitRatio}%`;
previewPanel.style.flex = `0 0 ${100 - ratio}%`;
} }
resizer.addEventListener('mousedown', (e) => { resizer.addEventListener('mousedown', (e) => {
@@ -373,12 +504,11 @@
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!isResizing) return; if (!isResizing) return;
const containerRect = mainContent.getBoundingClientRect(); const rect = mainContent.getBoundingClientRect();
const percentage = ((e.clientX - containerRect.left) / containerRect.width) * 100; const pct = Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100));
const clamped = Math.max(20, Math.min(80, percentage)); editorPanel.style.flex = `0 0 ${pct}%`;
editorPanel.style.flex = `0 0 ${clamped}%`; previewPanel.style.flex = `0 0 ${100 - pct}%`;
previewPanel.style.flex = `0 0 ${100 - clamped}%`; saveSetting('splitRatio', pct);
saveSetting('splitRatio', clamped);
}); });
document.addEventListener('mouseup', () => { document.addEventListener('mouseup', () => {
@@ -406,9 +536,7 @@
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dragCounter--; dragCounter--;
if (dragCounter === 0) { if (dragCounter === 0) dropOverlay.classList.add('hidden');
dropOverlay.classList.add('hidden');
}
}); });
document.addEventListener('dragover', (e) => { document.addEventListener('dragover', (e) => {
@@ -423,22 +551,16 @@
dropOverlay.classList.add('hidden'); dropOverlay.classList.add('hidden');
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length > 0) { for (const file of files) {
const file = files[0];
const filePath = file.path; const filePath = file.path;
if (filePath && typeof window.electronAPI !== 'undefined') { if (filePath && typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.readFile(filePath); const result = await window.electronAPI.readFile(filePath);
if (result.success) { if (result.success) {
setEditorContent(result.content, filePath); createNewTab(filePath, result.content);
} else {
statusText.textContent = '打开失败: ' + result.error;
} }
} else { } else {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (ev) => { reader.onload = (ev) => createNewTab(file.name, ev.target.result);
setEditorContent(ev.target.result, file.name);
};
reader.readAsText(file); reader.readAsText(file);
} }
} }
@@ -448,29 +570,24 @@
// ===== Keyboard Shortcuts ===== // ===== Keyboard Shortcuts =====
function initKeyboard() { function initKeyboard() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'o') { if (e.ctrlKey && e.key === 'o') { e.preventDefault(); handleOpenFile(); }
if (e.ctrlKey && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); }
if (e.ctrlKey && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); }
if (e.ctrlKey && e.key === '1') { e.preventDefault(); setViewMode('split'); }
if (e.ctrlKey && e.key === '2') { e.preventDefault(); setViewMode('editor'); }
if (e.ctrlKey && e.key === '3') { e.preventDefault(); setViewMode('preview'); }
if (e.ctrlKey && e.key === 't') { e.preventDefault(); createNewTab(null, ''); }
if (e.ctrlKey && e.key === 'w') { e.preventDefault(); if (activeTabId) closeTab(activeTabId); }
// Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
handleOpenFile(); if (tabs.length > 1) {
} const idx = getTabIndex(activeTabId);
if (e.ctrlKey && e.key === 's' && !e.shiftKey) { const next = e.shiftKey
e.preventDefault(); ? (idx - 1 + tabs.length) % tabs.length
handleSave(); : (idx + 1) % tabs.length;
} switchToTab(tabs[next].id);
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');
} }
}); });
} }
@@ -495,22 +612,29 @@
if (typeof window.electronAPI === 'undefined') return; if (typeof window.electronAPI === 'undefined') return;
window.electronAPI.onExternalModification((filePath) => { window.electronAPI.onExternalModification((filePath) => {
modifiedBanner.classList.remove('hidden'); // Only show banner if the modified file is the active tab
const tab = getActiveTab();
if (tab && tab.filePath === filePath) {
modifiedBanner.classList.remove('hidden');
}
}); });
btnReload.addEventListener('click', async () => { btnReload.addEventListener('click', async () => {
const tab = getActiveTab();
if (!tab || !tab.filePath) return;
const result = await window.electronAPI.reloadFile(); const result = await window.electronAPI.reloadFile();
if (result.success) { if (result.success) {
editor.value = result.content; editor.value = result.content;
currentContent = result.content; tab.content = result.content;
currentFilePath = result.filePath; tab.isModified = false;
isModified = false;
lineCountCache = 0; lineCountCache = 0;
updateLineNumbers(); updateLineNumbers();
renderMarkdown(result.content); renderMarkdown(result.content);
updateCursorPosition(); updateCursorPosition();
updateFileSize(); updateFileSize();
cachePreviewPositions(); cachePreviewPositions();
renderTabBar();
updateTitle();
} }
modifiedBanner.classList.add('hidden'); modifiedBanner.classList.add('hidden');
}); });
@@ -522,41 +646,39 @@
// ===== Unsaved Changes Warning ===== // ===== Unsaved Changes Warning =====
function initUnsavedWarning() { function initUnsavedWarning() {
const hasUnsaved = () => tabs.some(t => t.isModified);
if (typeof window.electronAPI === 'undefined') { if (typeof window.electronAPI === 'undefined') {
// Browser fallback
window.addEventListener('beforeunload', (e) => { window.addEventListener('beforeunload', (e) => {
if (isModified) { if (hasUnsaved()) { e.preventDefault(); e.returnValue = ''; }
e.preventDefault();
e.returnValue = '';
}
}); });
return; return;
} }
// Electron: handle close confirmation from main process
window.electronAPI.onConfirmClose(() => { window.electronAPI.onConfirmClose(() => {
if (!isModified) { if (!hasUnsaved()) {
window.electronAPI.forceClose(); window.electronAPI.forceClose();
return; return;
} }
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?');
// Show a simple confirm dialog if (shouldClose) window.electronAPI.forceClose();
const shouldClose = confirm('文件尚未保存,确定要关闭吗?');
if (shouldClose) {
window.electronAPI.forceClose();
}
}); });
} }
// ===== Event Listeners ===== // ===== Event Listeners =====
function initEventListeners() { function initEventListeners() {
editor.addEventListener('input', () => { editor.addEventListener('input', () => {
setModified(true); const tab = getActiveTab();
if (tab) {
tab.isModified = true;
tab.content = editor.value;
renderTabBar();
updateTitle();
}
scheduleUpdate(); scheduleUpdate();
}); });
editor.addEventListener('scroll', syncScroll); editor.addEventListener('scroll', syncScroll);
editor.addEventListener('click', updateCursorPosition); editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition); editor.addEventListener('keyup', updateCursorPosition);
editor.addEventListener('select', updateCursorPosition); editor.addEventListener('select', updateCursorPosition);
@@ -566,28 +688,25 @@
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'));
btnNewTab.addEventListener('click', () => createNewTab(null, ''));
btnWelcomeOpen.addEventListener('click', handleOpenFile); btnWelcomeOpen.addEventListener('click', handleOpenFile);
btnWelcomeNew.addEventListener('click', () => { btnWelcomeNew.addEventListener('click', () => createNewTab(null, ''));
setEditorContent('', null);
});
if (typeof window.electronAPI !== 'undefined') { if (typeof window.electronAPI !== 'undefined') {
// Main process sends file to open in a new tab
window.electronAPI.onFileOpenInTab((data) => {
createNewTab(data.filePath, data.content);
});
// Legacy: file opened via old dialog
window.electronAPI.onFileOpened((data) => { window.electronAPI.onFileOpened((data) => {
setEditorContent(data.content, data.filePath); createNewTab(data.filePath, data.content);
}); });
window.electronAPI.onMenuSave(() => { window.electronAPI.onMenuSave(() => handleSave());
handleSave(); window.electronAPI.onMenuSaveAs(() => handleSaveAs());
}); window.electronAPI.onViewModeChange((mode) => setViewMode(mode));
window.electronAPI.onMenuSaveAs(() => {
handleSaveAs();
});
window.electronAPI.onViewModeChange((mode) => {
setViewMode(mode);
});
} }
} }
@@ -602,11 +721,12 @@
initEventListeners(); initEventListeners();
initFileWatch(); initFileWatch();
initUnsavedWarning(); initUnsavedWarning();
updateLineNumbers();
// Restore saved view mode
const settings = loadSettings(); const settings = loadSettings();
setViewMode(settings.viewMode || 'split'); setViewMode(settings.viewMode || 'split');
// Create initial empty tab
createNewTab(null, '');
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+122
View File
@@ -167,6 +167,128 @@ html, body {
color: #fff; color: #fff;
} }
/* ===== Tab Bar ===== */
#tab-bar {
height: 36px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-end;
padding: 0 4px;
flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
}
#tab-bar::-webkit-scrollbar {
height: 0;
}
#tab-list {
display: flex;
align-items: flex-end;
gap: 1px;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
#tab-list::-webkit-scrollbar {
height: 0;
}
.tab-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
max-width: 180px;
min-width: 60px;
flex-shrink: 0;
transition: all 0.15s ease;
position: relative;
}
.tab-item:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.tab-item.active {
color: var(--text);
background: var(--bg);
border-bottom-color: var(--primary);
}
.tab-item.modified .tab-name::after {
content: ' •';
color: var(--primary);
}
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
color: var(--text-tertiary);
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
padding: 0;
opacity: 0;
transition: all 0.1s ease;
}
.tab-item:hover .tab-close,
.tab-item.active .tab-close {
opacity: 1;
}
.tab-close:hover {
background: var(--border);
color: var(--text);
}
.tab-add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius);
cursor: pointer;
margin-left: 2px;
margin-bottom: 4px;
flex-shrink: 0;
transition: all 0.15s ease;
}
.tab-add-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* ===== Main Content ===== */ /* ===== Main Content ===== */
#main-content { #main-content {
flex: 1; flex: 1;