新增多标签页支持:Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab 切换、拖拽多文件打开
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user