From 78f787a2db792230b44685d9a748a9bd4d6446a7 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 18 May 2026 13:28:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=9A=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=E6=94=AF=E6=8C=81=EF=BC=9ACtrl+T=20=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E3=80=81Ctrl+W=20=E5=85=B3=E9=97=AD=E3=80=81Ctrl+Tab=20?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E3=80=81=E6=8B=96=E6=8B=BD=E5=A4=9A=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 124 +++++++---- preload.js | 11 +- renderer/index.html | 13 +- renderer/renderer.js | 482 +++++++++++++++++++++++++++---------------- renderer/style.css | 122 +++++++++++ 5 files changed, 520 insertions(+), 232 deletions(-) diff --git a/main.js b/main.js index 7aab7f3..52dce35 100644 --- a/main.js +++ b/main.js @@ -3,7 +3,7 @@ const path = require('path'); const fs = require('fs'); let mainWindow = null; -let currentFilePath = null; +let activeFilePath = null; // Currently active tab's file path let pendingFilePath = null; let fileWatcher = null; @@ -25,7 +25,7 @@ function createWindow() { mainWindow.webContents.on('did-finish-load', () => { if (pendingFilePath) { - openFile(pendingFilePath); + openFileInTab(pendingFilePath); pendingFilePath = null; } }); @@ -38,18 +38,16 @@ function createWindow() { mainWindow.setMenu(null); - // Unsaved changes warning on close (with timeout fallback) + // Unsaved changes warning on close mainWindow.on('close', (e) => { e.preventDefault(); try { mainWindow.webContents.send('window:confirmClose'); } catch (err) { - // Renderer is gone, force close mainWindow.removeAllListeners('close'); mainWindow.close(); return; } - // Safety: if renderer doesn't respond within 5s, force close setTimeout(() => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.removeAllListeners('close'); @@ -64,9 +62,10 @@ function createWindow() { }); } -// File watcher - detect external modifications +// File watcher function startWatching(filePath) { stopWatching(); + if (!filePath) return; try { fileWatcher = fs.watch(filePath, (eventType) => { if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) { @@ -74,7 +73,7 @@ function startWatching(filePath) { } }); } 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, { - properties: ['openFile'], + properties: multi ? ['openFile', 'multiSelections'] : ['openFile'], filters: [ { name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }, { name: '所有文件', extensions: ['*'] } ] }); if (!result.canceled && result.filePaths.length > 0) { - return result.filePaths[0]; + return result.filePaths; } 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 { - const content = fs.readFileSync(filePath, 'utf-8'); - currentFilePath = filePath; + const content = readFileContent(filePath); + activeFilePath = filePath; startWatching(filePath); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); - mainWindow.webContents.send('file:opened', { filePath, content }); + mainWindow.webContents.send('file:openInTab', { filePath, content }); } catch (err) { 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 + +// Open single file via dialog ipcMain.handle('dialog:openFile', async () => { try { - const filePath = await showOpenDialog(); - if (filePath) { - const content = fs.readFileSync(filePath, 'utf-8'); - currentFilePath = filePath; - startWatching(filePath); - mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); - return { filePath, content }; + const files = await showOpenDialog(false); + if (files && files.length > 0) { + const content = readFileContent(files[0]); + activeFilePath = files[0]; + startWatching(files[0]); + mainWindow.setTitle(`MarkLite - ${path.basename(files[0])}`); + 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; } catch (err) { @@ -130,7 +169,7 @@ ipcMain.handle('dialog:openFile', async () => { ipcMain.handle('file:read', async (event, filePath) => { try { - const content = fs.readFileSync(filePath, 'utf-8'); + const content = readFileContent(filePath); return { success: true, content }; } catch (err) { return { success: false, error: err.message }; @@ -142,7 +181,7 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => { if (filePath) { stopWatching(); fs.writeFileSync(filePath, content, 'utf-8'); - currentFilePath = filePath; + activeFilePath = filePath; startWatching(filePath); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); return { success: true, filePath }; @@ -156,7 +195,7 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => { if (!result.canceled) { stopWatching(); fs.writeFileSync(result.filePath, content, 'utf-8'); - currentFilePath = result.filePath; + activeFilePath = result.filePath; startWatching(result.filePath); mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`); return { success: true, filePath: result.filePath }; @@ -179,7 +218,7 @@ ipcMain.handle('file:saveAs', async (event, { content }) => { if (!result.canceled) { stopWatching(); fs.writeFileSync(result.filePath, content, 'utf-8'); - currentFilePath = result.filePath; + activeFilePath = result.filePath; startWatching(result.filePath); mainWindow.setTitle(`MarkLite - ${path.basename(result.filePath)}`); return { success: true, filePath: result.filePath }; @@ -191,46 +230,45 @@ ipcMain.handle('file:saveAs', async (event, { content }) => { }); ipcMain.handle('file:getCurrentPath', () => { - return currentFilePath; + return activeFilePath; }); ipcMain.handle('file:stats', async (event, filePath) => { try { const stats = fs.statSync(filePath); - return { - success: true, - size: stats.size, - mtime: stats.mtime.toISOString() - }; + 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: '没有打开的文件' }; + if (!activeFilePath) return { success: false, error: '没有打开的文件' }; try { - const content = fs.readFileSync(currentFilePath, 'utf-8'); - return { success: true, content, filePath: currentFilePath }; + const content = readFileContent(activeFilePath); + return { success: true, content, filePath: activeFilePath }; } catch (err) { return { success: false, error: err.message }; } }); +// Tab switched - update active file tracking +ipcMain.handle('tab:switched', (event, filePath) => { + switchActiveFile(filePath); +}); + ipcMain.handle('window:forceClose', () => { stopWatching(); mainWindow.removeAllListeners('close'); mainWindow.close(); }); -// Handle file open from command line or file association +// Command line file function getCommandLineFile() { const args = process.argv.slice(1); if (args.length > 0 && !args[0].startsWith('--')) { const filePath = args[0]; - if (fs.existsSync(filePath)) { - return filePath; - } + if (fs.existsSync(filePath)) return filePath; } return null; } @@ -242,7 +280,7 @@ app.whenReady().then(() => { if (mainWindow && mainWindow.webContents.isLoading()) { pendingFilePath = filePath; } else if (mainWindow) { - openFile(filePath); + openFileInTab(filePath); } else { pendingFilePath = filePath; } @@ -257,13 +295,9 @@ app.whenReady().then(() => { }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } + if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } + if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/preload.js b/preload.js index f043511..f16d805 100644 --- a/preload.js +++ b/preload.js @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { // File operations openFile: () => ipcRenderer.invoke('dialog:openFile'), + openFiles: () => ipcRenderer.invoke('dialog:openFiles'), readFile: (filePath) => ipcRenderer.invoke('file:read', filePath), saveFile: (data) => ipcRenderer.invoke('file:save', data), saveFileAs: (data) => ipcRenderer.invoke('file:saveAs', data), @@ -10,19 +11,19 @@ contextBridge.exposeInMainWorld('electronAPI', { getFileStats: (filePath) => ipcRenderer.invoke('file:stats', filePath), reloadFile: () => ipcRenderer.invoke('file:reload'), + // Tab management + tabSwitched: (filePath) => ipcRenderer.invoke('tab:switched', filePath), + // Window control 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)), onMenuSave: (callback) => ipcRenderer.on('menu:save', () => callback()), onMenuSaveAs: (callback) => ipcRenderer.on('menu:saveAs', () => callback()), 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()), diff --git a/renderer/index.html b/renderer/index.html index 41cd540..2bede60 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -70,6 +70,17 @@ + +
+
+ +
+

💡 提示:可以直接拖拽 .md 文件到窗口打开

-

⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+1/2/3 切换视图

+

⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+1/2/3 切换视图 | Ctrl+T 新建标签

diff --git a/renderer/renderer.js b/renderer/renderer.js index 77bcd19..26739a8 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -17,6 +17,7 @@ const editorPanel = document.getElementById('editor-panel'); const previewPanel = document.getElementById('preview-panel'); const mainContent = document.getElementById('main-content'); + const tabList = document.getElementById('tab-list'); // Buttons const btnOpen = document.getElementById('btn-open'); @@ -29,20 +30,38 @@ const btnDark = document.getElementById('btn-dark'); const btnReload = document.getElementById('btn-reload'); const btnDismiss = document.getElementById('btn-dismiss'); + const btnNewTab = document.getElementById('btn-new-tab'); - // ===== State ===== - let currentFilePath = null; - let currentContent = ''; + // ===== Tab State ===== + let tabs = []; // Array of tab objects + let activeTabId = null; + let tabIdCounter = 0; + + // ===== Other State ===== let viewMode = 'split'; - let isModified = false; let updateTimer = null; 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 ===== function loadSettings() { try { - const s = JSON.parse(localStorage.getItem('marklite-settings') || '{}'); - return s; + return JSON.parse(localStorage.getItem('marklite-settings') || '{}'); } catch { return {}; } } @@ -91,12 +110,7 @@ const langClass = lang ? ` language-${lang}` : ''; return `
${highlighted}
`; }; - - marked.setOptions({ - gfm: true, - breaks: true, - pedantic: false - }); + marked.setOptions({ gfm: true, breaks: true, pedantic: false }); marked.use({ renderer }); } } @@ -108,19 +122,17 @@ return; } try { - const html = marked.parse(text); - preview.innerHTML = html; + preview.innerHTML = marked.parse(text); } catch (e) { preview.innerHTML = '

渲染错误: ' + e.message + '

'; } } - // ===== Line Numbers (optimized: batch innerHTML) ===== + // ===== Line Numbers ===== 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('
', i, '
'); @@ -132,11 +144,9 @@ 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}`; + const textBefore = text.substring(0, pos); + const lines = textBefore.split('\n'); + statusCursor.textContent = `行 ${lines.length}, 列 ${lines[lines.length - 1].length + 1}`; } // ===== File Size ===== @@ -152,183 +162,307 @@ statusSizeDivider.classList.add('visible'); } - // ===== Improved Scroll Sync (DOM position mapping) ===== - let previewElements = null; + // ===== Scroll Sync ===== let previewPositions = null; function cachePreviewPositions() { - previewElements = preview.children; previewPositions = []; - for (let i = 0; i < previewElements.length; i++) { - previewPositions.push(previewElements[i].offsetTop); + for (let i = 0; i < preview.children.length; i++) { + previewPositions.push(preview.children[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) 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 - ); - + const targetIndex = Math.min(Math.floor(ratio * previewPositions.length), previewPositions.length - 1); if (targetIndex >= 0 && previewPositions[targetIndex] !== undefined) { previewPanel.scrollTop = previewPositions[targetIndex]; } } - // ===== Update Preview ===== + // ===== Schedule Update ===== function scheduleUpdate() { if (updateTimer) clearTimeout(updateTimer); updateTimer = setTimeout(() => { - const text = editor.value; - renderMarkdown(text); + renderMarkdown(editor.value); updateLineNumbers(); updateFileSize(); cachePreviewPositions(); }, 150); } - // ===== File Operations ===== - function setModified(modified) { - isModified = modified; - const title = currentFilePath - ? `MarkLite - ${getFileName(currentFilePath)}${modified ? ' *' : ''}` - : `MarkLite - 未命名${modified ? ' *' : ''}`; - document.title = title; - } - + // ===== Helper ===== 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'); - } + // ===== Tab UI ===== + function renderTabBar() { + tabList.innerHTML = ''; + for (const tab of tabs) { + const el = document.createElement('div'); + 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 = ''; + 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) { - editor.value = text; - currentContent = text; - currentFilePath = filePath || null; - isModified = false; + function updateTitle() { + const tab = getActiveTab(); + if (tab) { + const name = tab.filePath ? getFileName(tab.filePath) : '未命名'; + 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; updateLineNumbers(); - renderMarkdown(text); - updateCursorPosition(); + renderMarkdown(tab.content); updateFileSize(); 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) { - document.title = `MarkLite - ${getFileName(filePath)}`; - statusText.textContent = getFileName(filePath); - updateFileStats(filePath); - } else { - document.title = 'MarkLite - 未命名'; - statusText.textContent = '未命名'; - statusSize.textContent = ''; - statusSizeDivider.classList.remove('visible'); + const existing = tabs.find(t => t.filePath === filePath); + if (existing) { + switchToTab(existing.id); + return existing; + } } + 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'); } + // 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() { if (typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.openFile(); if (result && !result.error) { - setEditorContent(result.content, result.filePath); + createNewTab(result.filePath, result.content); } } else { const input = document.createElement('input'); input.type = 'file'; input.accept = '.md,.markdown,.txt'; + input.multiple = true; input.onchange = (e) => { - const file = e.target.files[0]; - if (file) { + Array.from(e.target.files).forEach(file => { const reader = new FileReader(); reader.onload = (ev) => { - setEditorContent(ev.target.result, file.name); + createNewTab(file.name, ev.target.result); }; reader.readAsText(file); - } + }); }; input.click(); } } async function handleSave() { + const tab = getActiveTab(); + if (!tab) return; + if (typeof window.electronAPI !== 'undefined') { const result = await window.electronAPI.saveFile({ - filePath: currentFilePath, + filePath: tab.filePath, content: editor.value }); if (result.success) { - currentFilePath = result.filePath; - currentContent = editor.value; - setModified(false); + tab.filePath = result.filePath; + tab.content = editor.value; + tab.isModified = false; + renderTabBar(); + updateTitle(); statusText.textContent = '已保存'; - setTimeout(() => { - statusText.textContent = getFileName(currentFilePath) || '就绪'; - }, 2000); + setTimeout(() => updateTitle(), 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.download = tab.filePath || 'untitled.md'; a.click(); URL.revokeObjectURL(a.href); - setModified(false); + tab.isModified = false; + renderTabBar(); + updateTitle(); } } async function handleSaveAs() { + const tab = getActiveTab(); + if (!tab) return; + if (typeof window.electronAPI !== 'undefined') { - const result = await window.electronAPI.saveFileAs({ - content: editor.value - }); + const result = await window.electronAPI.saveFileAs({ content: editor.value }); if (result.success) { - currentFilePath = result.filePath; - currentContent = editor.value; - setModified(false); + tab.filePath = result.filePath; + tab.content = editor.value; + tab.isModified = false; + renderTabBar(); + updateTitle(); statusText.textContent = '已保存'; - setTimeout(() => { - statusText.textContent = getFileName(currentFilePath) || '就绪'; - }, 2000); + setTimeout(() => updateTitle(), 2000); } } else { handleSave(); } } - // ===== View Mode (with localStorage) ===== + // ===== View Mode ===== 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'); @@ -347,20 +481,17 @@ renderMarkdown(editor.value); break; } - saveSetting('viewMode', mode); } - // ===== Resizer (with localStorage) ===== + // ===== Resizer ===== 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}%`; + editorPanel.style.flex = `0 0 ${settings.splitRatio}%`; + previewPanel.style.flex = `0 0 ${100 - settings.splitRatio}%`; } resizer.addEventListener('mousedown', (e) => { @@ -373,12 +504,11 @@ 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); + const rect = mainContent.getBoundingClientRect(); + const pct = Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100)); + editorPanel.style.flex = `0 0 ${pct}%`; + previewPanel.style.flex = `0 0 ${100 - pct}%`; + saveSetting('splitRatio', pct); }); document.addEventListener('mouseup', () => { @@ -406,9 +536,7 @@ e.preventDefault(); e.stopPropagation(); dragCounter--; - if (dragCounter === 0) { - dropOverlay.classList.add('hidden'); - } + if (dragCounter === 0) dropOverlay.classList.add('hidden'); }); document.addEventListener('dragover', (e) => { @@ -423,22 +551,16 @@ dropOverlay.classList.add('hidden'); const files = e.dataTransfer.files; - if (files.length > 0) { - const file = files[0]; + for (const file of files) { 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; + createNewTab(filePath, result.content); } } else { const reader = new FileReader(); - reader.onload = (ev) => { - setEditorContent(ev.target.result, file.name); - }; + reader.onload = (ev) => createNewTab(file.name, ev.target.result); reader.readAsText(file); } } @@ -448,29 +570,24 @@ // ===== Keyboard Shortcuts ===== function initKeyboard() { 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(); - 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 (tabs.length > 1) { + const idx = getTabIndex(activeTabId); + const next = e.shiftKey + ? (idx - 1 + tabs.length) % tabs.length + : (idx + 1) % tabs.length; + switchToTab(tabs[next].id); + } } }); } @@ -495,22 +612,29 @@ if (typeof window.electronAPI === 'undefined') return; 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 () => { + const tab = getActiveTab(); + if (!tab || !tab.filePath) return; const result = await window.electronAPI.reloadFile(); if (result.success) { editor.value = result.content; - currentContent = result.content; - currentFilePath = result.filePath; - isModified = false; + tab.content = result.content; + tab.isModified = false; lineCountCache = 0; updateLineNumbers(); renderMarkdown(result.content); updateCursorPosition(); updateFileSize(); cachePreviewPositions(); + renderTabBar(); + updateTitle(); } modifiedBanner.classList.add('hidden'); }); @@ -522,41 +646,39 @@ // ===== Unsaved Changes Warning ===== function initUnsavedWarning() { + const hasUnsaved = () => tabs.some(t => t.isModified); + if (typeof window.electronAPI === 'undefined') { - // Browser fallback window.addEventListener('beforeunload', (e) => { - if (isModified) { - e.preventDefault(); - e.returnValue = ''; - } + if (hasUnsaved()) { e.preventDefault(); e.returnValue = ''; } }); return; } - // Electron: handle close confirmation from main process window.electronAPI.onConfirmClose(() => { - if (!isModified) { + if (!hasUnsaved()) { window.electronAPI.forceClose(); return; } - - // Show a simple confirm dialog - const shouldClose = confirm('文件尚未保存,确定要关闭吗?'); - if (shouldClose) { - window.electronAPI.forceClose(); - } + const shouldClose = confirm('有文件尚未保存,确定要关闭吗?'); + if (shouldClose) window.electronAPI.forceClose(); }); } // ===== Event Listeners ===== function initEventListeners() { editor.addEventListener('input', () => { - setModified(true); + const tab = getActiveTab(); + if (tab) { + tab.isModified = true; + tab.content = editor.value; + renderTabBar(); + updateTitle(); + } scheduleUpdate(); }); editor.addEventListener('scroll', syncScroll); - editor.addEventListener('click', updateCursorPosition); editor.addEventListener('keyup', updateCursorPosition); editor.addEventListener('select', updateCursorPosition); @@ -566,28 +688,25 @@ btnSplit.addEventListener('click', () => setViewMode('split')); btnEditor.addEventListener('click', () => setViewMode('editor')); btnPreview.addEventListener('click', () => setViewMode('preview')); + btnNewTab.addEventListener('click', () => createNewTab(null, '')); btnWelcomeOpen.addEventListener('click', handleOpenFile); - btnWelcomeNew.addEventListener('click', () => { - setEditorContent('', null); - }); + btnWelcomeNew.addEventListener('click', () => createNewTab(null, '')); 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) => { - setEditorContent(data.content, data.filePath); + createNewTab(data.filePath, data.content); }); - window.electronAPI.onMenuSave(() => { - handleSave(); - }); - - window.electronAPI.onMenuSaveAs(() => { - handleSaveAs(); - }); - - window.electronAPI.onViewModeChange((mode) => { - setViewMode(mode); - }); + window.electronAPI.onMenuSave(() => handleSave()); + window.electronAPI.onMenuSaveAs(() => handleSaveAs()); + window.electronAPI.onViewModeChange((mode) => setViewMode(mode)); } } @@ -602,11 +721,12 @@ initEventListeners(); initFileWatch(); initUnsavedWarning(); - updateLineNumbers(); - // Restore saved view mode const settings = loadSettings(); setViewMode(settings.viewMode || 'split'); + + // Create initial empty tab + createNewTab(null, ''); } if (document.readyState === 'loading') { diff --git a/renderer/style.css b/renderer/style.css index b96adac..ee61be8 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -167,6 +167,128 @@ html, body { 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 { flex: 1;