opt: 8项优化——搜索高亮精度/滚动同步/行号同步/本地图片/拖拽提示/MRU标签/侧边栏调宽/toast组件
This commit is contained in:
+126
-17
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user