diff --git a/renderer/renderer.js b/renderer/renderer.js
index 5ffcab5..b5e0590 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -56,6 +56,7 @@
let tabs = []; // Array of tab objects
let activeTabId = null;
let tabIdCounter = 0;
+ let mruTabStack = []; // Most-recently-used tab IDs for Ctrl+Tab
// ===== Other State =====
let viewMode = 'split';
@@ -69,6 +70,35 @@
let searchCaseSensitive = false;
let searchUseRegex = false;
+ // ===== Character Width Measurement (#1) =====
+ const _charWidthCanvas = document.createElement('canvas');
+ const _charWidthCtx = _charWidthCanvas.getContext('2d');
+ let _cachedCharWidth = null;
+ function getCharWidth() {
+ if (_cachedCharWidth !== null) return _cachedCharWidth;
+ const style = getComputedStyle(editor);
+ _charWidthCtx.font = `${style.fontSize} ${style.fontFamily}`;
+ _cachedCharWidth = _charWidthCtx.measureText('M').width;
+ return _cachedCharWidth;
+ }
+ // Recalculate on font/theme change
+ new MutationObserver(() => { _cachedCharWidth = null; }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+
+ // ===== Toast Notification (#5) =====
+ function showToast(message, duration) {
+ duration = duration || 3000;
+ let toast = document.getElementById('toast-notification');
+ if (!toast) {
+ toast = document.createElement('div');
+ toast.id = 'toast-notification';
+ document.getElementById('app').appendChild(toast);
+ }
+ toast.textContent = message;
+ toast.classList.add('show');
+ clearTimeout(toast._timer);
+ toast._timer = setTimeout(() => toast.classList.remove('show'), duration);
+ }
+
// ===== Tab Object =====
function createTab(filePath, content) {
const id = ++tabIdCounter;
@@ -146,6 +176,31 @@
const langClass = lang ? ` language-${lang}` : '';
return `
${highlighted}
`;
};
+ // 处理相对路径图片 → file:// URL
+ renderer.image = function (hrefOrToken, title, text) {
+ const href = (typeof hrefOrToken === 'object' && hrefOrToken !== null)
+ ? hrefOrToken.href
+ : hrefOrToken;
+ const alt = (typeof hrefOrToken === 'object' && hrefOrToken !== null)
+ ? hrefOrToken.text
+ : text;
+ const titleAttr = (typeof hrefOrToken === 'object' && hrefOrToken !== null)
+ ? hrefOrToken.title
+ : title;
+ let src = href;
+ // 相对路径转绝对路径
+ if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
+ const tab = getActiveTab();
+ if (tab && tab.filePath) {
+ const dir = tab.filePath.replace(/[/\\][^/\\]+$/, '');
+ // 简单路径拼接(不依赖 require('path'))
+ const sep = dir.includes('\\') ? '\\' : '/';
+ src = 'file://' + (dir + sep + src).replace(/\\/g, '/');
+ }
+ }
+ const titleStr = titleAttr ? ` title="${titleAttr}"` : '';
+ return `
`;
+ };
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
marked.use({ renderer });
}
@@ -411,13 +466,14 @@
}
// 去重并排序
- const uniqueStarts = [...new Set(blockStartLines)].sort((a, b) => a - b);
+ const uniqueStartsArr = [...new Set(blockStartLines)].sort((a, b) => a - b);
+ const uniqueStartsSet = new Set(uniqueStartsArr);
// 将编辑器行号映射到预览 DOM 位置
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
lineToPreviewMap = new Float32Array(totalLines);
- if (uniqueStarts.length === 0) {
+ if (uniqueStartsArr.length === 0) {
// 没有找到块边界,回退到线性映射
for (let i = 0; i < totalLines; i++) {
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1];
@@ -427,23 +483,23 @@
// 将块起始行映射到预览 DOM 索引
const domCount = previewPositions.length;
- for (let i = 0; i < uniqueStarts.length; i++) {
- const lineIdx = uniqueStarts[i];
- const domIdx = Math.min(Math.floor((i / uniqueStarts.length) * domCount), domCount - 1);
+ for (let i = 0; i < uniqueStartsArr.length; i++) {
+ const lineIdx = uniqueStartsArr[i];
+ const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1);
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
}
// 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
- if (lineToPreviewMap[i] !== 0 && uniqueStarts.includes(i)) continue;
+ if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue;
// 找到前后最近的已映射行
let prevLine = -1, nextLine = -1;
for (let j = i - 1; j >= 0; j--) {
- if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { prevLine = j; break; }
+ if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break; }
}
for (let j = i + 1; j < totalLines; j++) {
- if (lineToPreviewMap[j] !== 0 || uniqueStarts.includes(j)) { nextLine = j; break; }
+ if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break; }
}
if (prevLine === -1 && nextLine === -1) {
@@ -611,6 +667,7 @@
requestAnimationFrame(() => {
editor.scrollTop = tab.scrollTop;
editor.scrollLeft = tab.scrollLeft;
+ lineNumbers.scrollTop = tab.scrollTop;
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
previewPanel.scrollTop = tab.previewScrollTop;
updateCursorPosition();
@@ -660,6 +717,12 @@
// Save current state
saveCurrentTabState();
+ // MRU: record previous tab before switching
+ if (activeTabId) {
+ mruTabStack = mruTabStack.filter(id => id !== activeTabId);
+ mruTabStack.unshift(activeTabId);
+ }
+
// Switch
activeTabId = tabId;
const tab = getActiveTab();
@@ -687,6 +750,8 @@
// Remove tab
tabs.splice(index, 1);
+ // Clean MRU stack
+ mruTabStack = mruTabStack.filter(id => id !== tabId);
// If we closed the active tab, switch to another
if (tabId === activeTabId) {
@@ -899,13 +964,14 @@
const files = e.dataTransfer.files;
const allowedExts = ['.md', '.markdown', '.txt'];
+ let rejected = 0;
for (const file of files) {
const filePath = file.path;
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
- if (!allowedExts.includes(ext)) continue;
+ if (!allowedExts.includes(ext)) { rejected++; continue; }
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
if (file.size > MAX_FILE_SIZE) {
- alert(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`);
+ showToast(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`);
continue;
}
if (filePath && typeof window.electronAPI !== 'undefined') {
@@ -919,6 +985,9 @@
reader.readAsText(file);
}
}
+ if (rejected > 0) {
+ showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`);
+ }
});
}
@@ -933,15 +1002,25 @@
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
+ // Ctrl+Tab / Ctrl+Shift+Tab to cycle tabs (MRU order)
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault();
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);
+ if (e.shiftKey) {
+ // Ctrl+Shift+Tab: go back in MRU history
+ if (mruTabStack.length > 0) {
+ const targetId = mruTabStack.shift();
+ // Verify target still exists
+ if (tabs.find(t => t.id === targetId)) {
+ switchToTab(targetId);
+ }
+ }
+ } else {
+ // Ctrl+Tab: forward in MRU (just switch to next in array as fallback)
+ const idx = getTabIndex(activeTabId);
+ const next = (idx + 1) % tabs.length;
+ switchToTab(tabs[next].id);
+ }
}
}
// Search & Replace
@@ -1057,6 +1136,8 @@
editor.addEventListener('scroll', () => {
syncScroll();
+ // 行号跟随编辑器滚动
+ lineNumbers.scrollTop = editor.scrollTop;
// 大文件滚动时重新渲染虚拟行号
if (lineCountCache > 2000) {
renderVirtualLineNumbers(lineCountCache);
@@ -1202,7 +1283,7 @@
const col = pos - lineStarts[line];
return {
top: line * lineHeight + paddingTop,
- left: col * 8 + paddingLeft
+ left: col * getCharWidth() + paddingLeft
};
}
@@ -1289,6 +1370,34 @@
function initSidebar() {
const settings = loadSettings();
if (settings.sidebarCollapsed) sidebar.classList.add('collapsed');
+ if (settings.sidebarWidth) {
+ sidebar.style.width = settings.sidebarWidth + 'px';
+ }
+
+ // Sidebar resize handle (#10)
+ const handle = document.createElement('div');
+ handle.className = 'sidebar-resize-handle';
+ sidebar.appendChild(handle);
+ let isSidebarResizing = false;
+ handle.addEventListener('mousedown', (e) => {
+ isSidebarResizing = true;
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ e.preventDefault();
+ });
+ document.addEventListener('mousemove', (e) => {
+ if (!isSidebarResizing) return;
+ const newWidth = Math.max(180, Math.min(500, e.clientX));
+ sidebar.style.width = newWidth + 'px';
+ saveSetting('sidebarWidth', newWidth);
+ });
+ document.addEventListener('mouseup', () => {
+ if (isSidebarResizing) {
+ isSidebarResizing = false;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ }
+ });
btnOpenFolder.addEventListener('click', openFolder);
if (typeof window.electronAPI !== 'undefined') {
diff --git a/renderer/style.css b/renderer/style.css
index 20a5d46..3b60d37 100644
--- a/renderer/style.css
+++ b/renderer/style.css
@@ -750,6 +750,7 @@ html, body {
flex-shrink: 0;
overflow: hidden;
user-select: none;
+ position: relative;
}
#sidebar.collapsed {
@@ -1064,3 +1065,46 @@ html, body {
.welcome-content {
animation: fadeIn 0.3s ease;
}
+
+/* ===== Toast Notification ===== */
+#toast-notification {
+ position: fixed;
+ bottom: 40px;
+ left: 50%;
+ transform: translateX(-50%) translateY(20px);
+ background: var(--text);
+ color: var(--bg);
+ padding: 8px 20px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: var(--font-ui);
+ box-shadow: var(--shadow-lg);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ z-index: 9999;
+ max-width: 480px;
+ text-align: center;
+}
+
+#toast-notification.show {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ pointer-events: auto;
+}
+
+/* ===== Sidebar Resize Handle ===== */
+.sidebar-resize-handle {
+ position: absolute;
+ top: 0;
+ right: -2px;
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+ z-index: 5;
+}
+
+.sidebar-resize-handle:hover,
+.sidebar-resize-handle:active {
+ background: var(--primary);
+}