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 `${alt}`; + }; 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); +}