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 @@
+
+
+
文件已被外部程序修改
@@ -151,7 +162,7 @@
💡 提示:可以直接拖拽 .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;