opt: 8项优化——搜索高亮精度/滚动同步/行号同步/本地图片/拖拽提示/MRU标签/侧边栏调宽/toast组件
This commit is contained in:
+124
-15
@@ -56,6 +56,7 @@
|
|||||||
let tabs = []; // Array of tab objects
|
let tabs = []; // Array of tab objects
|
||||||
let activeTabId = null;
|
let activeTabId = null;
|
||||||
let tabIdCounter = 0;
|
let tabIdCounter = 0;
|
||||||
|
let mruTabStack = []; // Most-recently-used tab IDs for Ctrl+Tab
|
||||||
|
|
||||||
// ===== Other State =====
|
// ===== Other State =====
|
||||||
let viewMode = 'split';
|
let viewMode = 'split';
|
||||||
@@ -69,6 +70,35 @@
|
|||||||
let searchCaseSensitive = false;
|
let searchCaseSensitive = false;
|
||||||
let searchUseRegex = 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 =====
|
// ===== Tab Object =====
|
||||||
function createTab(filePath, content) {
|
function createTab(filePath, content) {
|
||||||
const id = ++tabIdCounter;
|
const id = ++tabIdCounter;
|
||||||
@@ -146,6 +176,31 @@
|
|||||||
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>`;
|
||||||
};
|
};
|
||||||
|
// 处理相对路径图片 → 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.setOptions({ gfm: true, breaks: true, pedantic: false });
|
||||||
marked.use({ renderer });
|
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 位置
|
// 将编辑器行号映射到预览 DOM 位置
|
||||||
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
|
// 每个块的起始行对应一个预览元素,用线性插值填充中间行
|
||||||
lineToPreviewMap = new Float32Array(totalLines);
|
lineToPreviewMap = new Float32Array(totalLines);
|
||||||
|
|
||||||
if (uniqueStarts.length === 0) {
|
if (uniqueStartsArr.length === 0) {
|
||||||
// 没有找到块边界,回退到线性映射
|
// 没有找到块边界,回退到线性映射
|
||||||
for (let i = 0; i < totalLines; i++) {
|
for (let i = 0; i < totalLines; i++) {
|
||||||
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1];
|
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * previewPositions[previewPositions.length - 1];
|
||||||
@@ -427,23 +483,23 @@
|
|||||||
|
|
||||||
// 将块起始行映射到预览 DOM 索引
|
// 将块起始行映射到预览 DOM 索引
|
||||||
const domCount = previewPositions.length;
|
const domCount = previewPositions.length;
|
||||||
for (let i = 0; i < uniqueStarts.length; i++) {
|
for (let i = 0; i < uniqueStartsArr.length; i++) {
|
||||||
const lineIdx = uniqueStarts[i];
|
const lineIdx = uniqueStartsArr[i];
|
||||||
const domIdx = Math.min(Math.floor((i / uniqueStarts.length) * domCount), domCount - 1);
|
const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1);
|
||||||
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
|
lineToPreviewMap[lineIdx] = previewPositions[domIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 线性插值填充所有行
|
// 线性插值填充所有行
|
||||||
for (let i = 0; i < totalLines; i++) {
|
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;
|
let prevLine = -1, nextLine = -1;
|
||||||
for (let j = i - 1; j >= 0; j--) {
|
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++) {
|
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) {
|
if (prevLine === -1 && nextLine === -1) {
|
||||||
@@ -611,6 +667,7 @@
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editor.scrollTop = tab.scrollTop;
|
editor.scrollTop = tab.scrollTop;
|
||||||
editor.scrollLeft = tab.scrollLeft;
|
editor.scrollLeft = tab.scrollLeft;
|
||||||
|
lineNumbers.scrollTop = tab.scrollTop;
|
||||||
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
|
editor.setSelectionRange(tab.selectionStart, tab.selectionEnd);
|
||||||
previewPanel.scrollTop = tab.previewScrollTop;
|
previewPanel.scrollTop = tab.previewScrollTop;
|
||||||
updateCursorPosition();
|
updateCursorPosition();
|
||||||
@@ -660,6 +717,12 @@
|
|||||||
// Save current state
|
// Save current state
|
||||||
saveCurrentTabState();
|
saveCurrentTabState();
|
||||||
|
|
||||||
|
// MRU: record previous tab before switching
|
||||||
|
if (activeTabId) {
|
||||||
|
mruTabStack = mruTabStack.filter(id => id !== activeTabId);
|
||||||
|
mruTabStack.unshift(activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
// Switch
|
// Switch
|
||||||
activeTabId = tabId;
|
activeTabId = tabId;
|
||||||
const tab = getActiveTab();
|
const tab = getActiveTab();
|
||||||
@@ -687,6 +750,8 @@
|
|||||||
|
|
||||||
// Remove tab
|
// Remove tab
|
||||||
tabs.splice(index, 1);
|
tabs.splice(index, 1);
|
||||||
|
// Clean MRU stack
|
||||||
|
mruTabStack = mruTabStack.filter(id => id !== tabId);
|
||||||
|
|
||||||
// If we closed the active tab, switch to another
|
// If we closed the active tab, switch to another
|
||||||
if (tabId === activeTabId) {
|
if (tabId === activeTabId) {
|
||||||
@@ -899,13 +964,14 @@
|
|||||||
|
|
||||||
const files = e.dataTransfer.files;
|
const files = e.dataTransfer.files;
|
||||||
const allowedExts = ['.md', '.markdown', '.txt'];
|
const allowedExts = ['.md', '.markdown', '.txt'];
|
||||||
|
let rejected = 0;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = file.path;
|
const filePath = file.path;
|
||||||
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
|
const ext = filePath ? filePath.substring(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||||
if (!allowedExts.includes(ext)) continue;
|
if (!allowedExts.includes(ext)) { rejected++; continue; }
|
||||||
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
|
// Electron 主进程已有文件大小检查,此处为浏览器模式兜底
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
alert(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`);
|
showToast(`"${file.name}" 过大(${formatBytes(file.size)}),暂不支持超过 20MB 的文件`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (filePath && typeof window.electronAPI !== 'undefined') {
|
if (filePath && typeof window.electronAPI !== 'undefined') {
|
||||||
@@ -919,6 +985,9 @@
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (rejected > 0) {
|
||||||
|
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,17 +1002,27 @@
|
|||||||
if (e.ctrlKey && e.key === '3') { e.preventDefault(); setViewMode('preview'); }
|
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 === 't') { e.preventDefault(); createNewTab(null, ''); }
|
||||||
if (e.ctrlKey && e.key === 'w') { e.preventDefault(); if (activeTabId) closeTab(activeTabId); }
|
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') {
|
if (e.ctrlKey && e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (tabs.length > 1) {
|
if (tabs.length > 1) {
|
||||||
|
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 idx = getTabIndex(activeTabId);
|
||||||
const next = e.shiftKey
|
const next = (idx + 1) % tabs.length;
|
||||||
? (idx - 1 + tabs.length) % tabs.length
|
|
||||||
: (idx + 1) % tabs.length;
|
|
||||||
switchToTab(tabs[next].id);
|
switchToTab(tabs[next].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Search & Replace
|
// Search & Replace
|
||||||
if (e.ctrlKey && e.key === 'f') { e.preventDefault(); openSearch(false); }
|
if (e.ctrlKey && e.key === 'f') { e.preventDefault(); openSearch(false); }
|
||||||
if (e.ctrlKey && e.key === 'h') { e.preventDefault(); openSearch(true); }
|
if (e.ctrlKey && e.key === 'h') { e.preventDefault(); openSearch(true); }
|
||||||
@@ -1057,6 +1136,8 @@
|
|||||||
|
|
||||||
editor.addEventListener('scroll', () => {
|
editor.addEventListener('scroll', () => {
|
||||||
syncScroll();
|
syncScroll();
|
||||||
|
// 行号跟随编辑器滚动
|
||||||
|
lineNumbers.scrollTop = editor.scrollTop;
|
||||||
// 大文件滚动时重新渲染虚拟行号
|
// 大文件滚动时重新渲染虚拟行号
|
||||||
if (lineCountCache > 2000) {
|
if (lineCountCache > 2000) {
|
||||||
renderVirtualLineNumbers(lineCountCache);
|
renderVirtualLineNumbers(lineCountCache);
|
||||||
@@ -1202,7 +1283,7 @@
|
|||||||
const col = pos - lineStarts[line];
|
const col = pos - lineStarts[line];
|
||||||
return {
|
return {
|
||||||
top: line * lineHeight + paddingTop,
|
top: line * lineHeight + paddingTop,
|
||||||
left: col * 8 + paddingLeft
|
left: col * getCharWidth() + paddingLeft
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,6 +1370,34 @@
|
|||||||
function initSidebar() {
|
function initSidebar() {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
if (settings.sidebarCollapsed) sidebar.classList.add('collapsed');
|
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);
|
btnOpenFolder.addEventListener('click', openFolder);
|
||||||
if (typeof window.electronAPI !== 'undefined') {
|
if (typeof window.electronAPI !== 'undefined') {
|
||||||
|
|||||||
@@ -750,6 +750,7 @@ html, body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar.collapsed {
|
#sidebar.collapsed {
|
||||||
@@ -1064,3 +1065,46 @@ html, body {
|
|||||||
.welcome-content {
|
.welcome-content {
|
||||||
animation: fadeIn 0.3s ease;
|
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