opt: 8项优化——搜索高亮精度/滚动同步/行号同步/本地图片/拖拽提示/MRU标签/侧边栏调宽/toast组件

This commit is contained in:
thzxx
2026-05-21 13:30:11 +08:00
parent d178f5b3f6
commit b185771c41
2 changed files with 170 additions and 17 deletions
+126 -17
View File
@@ -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 `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
};
// 处理相对路径图片 → 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 `<img src="${src}" alt="${alt}"${titleStr}>`;
};
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') {