fix: 搜索高亮跟随滚动同步 + 行号动态行高测量修复 v1.2.1

This commit is contained in:
thzxx
2026-05-27 17:34:30 +08:00
parent 8fea500cc1
commit eb05e9d1b1
5 changed files with 107 additions and 33 deletions
+6 -7
View File
@@ -195,16 +195,15 @@ function switchActiveFile(filePath) {
// IPC Handlers // IPC Handlers
// Save file to a specific path with watcher management // Save file to a specific path with watcher management
function saveToPath(filePath, content) { async function saveToPath(filePath, content) {
stopWatching(); stopWatching();
try { try {
isSelfWriting = true; isSelfWriting = true;
fs.writeFileSync(filePath, content, 'utf-8'); await fs.promises.writeFile(filePath, content, 'utf-8');
// 写入后短暂延迟再恢复监听,让 fs.watch 错过自身写入的事件
isSelfWriting = false; isSelfWriting = false;
} catch (err) { } catch (err) {
isSelfWriting = false; isSelfWriting = false;
startWatching(activeFilePath); // Restore watcher on failure startWatching(activeFilePath);
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
activeFilePath = filePath; activeFilePath = filePath;
@@ -250,7 +249,7 @@ ipcMain.handle('file:read', async (event, filePath) => {
ipcMain.handle('file:save', async (event, { filePath, content }) => { ipcMain.handle('file:save', async (event, { filePath, content }) => {
try { try {
if (filePath) { if (filePath) {
return saveToPath(filePath, content); return await saveToPath(filePath, content);
} else { } else {
const result = await dialog.showSaveDialog(mainWindow, { const result = await dialog.showSaveDialog(mainWindow, {
filters: [ filters: [
@@ -258,7 +257,7 @@ ipcMain.handle('file:save', async (event, { filePath, content }) => {
] ]
}); });
if (!result.canceled) { if (!result.canceled) {
return saveToPath(result.filePath, content); return await saveToPath(result.filePath, content);
} }
return { success: false, canceled: true }; return { success: false, canceled: true };
} }
@@ -275,7 +274,7 @@ ipcMain.handle('file:saveAs', async (event, { content }) => {
] ]
}); });
if (!result.canceled) { if (!result.canceled) {
return saveToPath(result.filePath, content); return await saveToPath(result.filePath, content);
} }
return { success: false, canceled: true }; return { success: false, canceled: true };
} catch (err) { } catch (err) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "1.2.0", "version": "1.2.1",
"description": "Lightweight Markdown Reader for Windows", "description": "Lightweight Markdown Reader for Windows",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+1
View File
@@ -112,6 +112,7 @@
<!-- Search & Replace Bar --> <!-- Search & Replace Bar -->
<div id="search-bar" class="hidden"> <div id="search-bar" class="hidden">
<div class="search-row"> <div class="search-row">
<button id="btn-toggle-replace" class="search-opt-btn" title="展开替换行"></button>
<input type="text" id="search-input" placeholder="查找..." /> <input type="text" id="search-input" placeholder="查找..." />
<span id="search-count"></span> <span id="search-count"></span>
<button id="btn-case" class="search-opt-btn" title="区分大小写 (Alt+C)">Aa</button> <button id="btn-case" class="search-opt-btn" title="区分大小写 (Alt+C)">Aa</button>
+90 -25
View File
@@ -38,6 +38,7 @@
const btnSearchClose = document.getElementById('btn-search-close'); const btnSearchClose = document.getElementById('btn-search-close');
const btnReplace = document.getElementById('btn-replace'); const btnReplace = document.getElementById('btn-replace');
const btnReplaceAll = document.getElementById('btn-replace-all'); const btnReplaceAll = document.getElementById('btn-replace-all');
const btnToggleReplace = document.getElementById('btn-toggle-replace');
// Buttons // Buttons
const btnOpen = document.getElementById('btn-open'); const btnOpen = document.getElementById('btn-open');
@@ -69,6 +70,7 @@
let searchIndex = -1; let searchIndex = -1;
let searchCaseSensitive = false; let searchCaseSensitive = false;
let searchUseRegex = false; let searchUseRegex = false;
let searchHighlightsOverlay = null; // 当前搜索高亮 overlay 元素引用
// ===== Character Width Measurement (#1) ===== // ===== Character Width Measurement (#1) =====
const _charWidthCanvas = document.createElement('canvas'); const _charWidthCanvas = document.createElement('canvas');
@@ -84,6 +86,27 @@
// Recalculate on font/theme change // Recalculate on font/theme change
new MutationObserver(() => { _cachedCharWidth = null; }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); new MutationObserver(() => { _cachedCharWidth = null; }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// ===== Actual Line Height Measurement =====
// textarea 的实际渲染行高可能与 CSS line-height 计算值有微小差异,
// 导致行号面板与编辑器内容逐渐错位。动态测量真实行高来解决。
let _cachedLineHeight = null;
function getLineHeight() {
if (_cachedLineHeight !== null) return _cachedLineHeight;
const style = getComputedStyle(editor);
const measure = document.createElement('div');
measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;`;
measure.textContent = 'X';
document.body.appendChild(measure);
const singleHeight = measure.offsetHeight;
measure.textContent = 'X\nX';
const doubleHeight = measure.offsetHeight;
document.body.removeChild(measure);
_cachedLineHeight = doubleHeight - singleHeight || 20.8;
return _cachedLineHeight;
}
// 字体/主题变化时清除缓存
new MutationObserver(() => { _cachedLineHeight = null; _cachedCharWidth = null; }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// ===== Toast Notification (#5) ===== // ===== Toast Notification (#5) =====
function showToast(message, duration) { function showToast(message, duration) {
duration = duration || 3000; duration = duration || 3000;
@@ -348,7 +371,7 @@
// 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快) // 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
const arr = new Array(count); const arr = new Array(count);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
arr[i] = '<div class="line-num">' + (i + 1) + '</div>'; arr[i] = '<div class="line-num" style="height:' + getLineHeight() + 'px">' + (i + 1) + '</div>';
} }
lineNumbers.innerHTML = arr.join(''); lineNumbers.innerHTML = arr.join('');
} }
@@ -356,7 +379,7 @@
// 虚拟化行号:只渲染当前可视区域附近的行号 // 虚拟化行号:只渲染当前可视区域附近的行号
function renderVirtualLineNumbers(totalLines) { function renderVirtualLineNumbers(totalLines) {
const lineHeight = 20.8; // 与 CSS line-height 一致 const lineHeight = getLineHeight();
const scrollTop = editor.scrollTop; const scrollTop = editor.scrollTop;
const viewportHeight = editor.clientHeight; const viewportHeight = editor.clientHeight;
const buffer = 20; // 上下缓冲行数 const buffer = 20; // 上下缓冲行数
@@ -518,7 +541,7 @@
function syncScroll() { function syncScroll() {
if (viewMode !== 'split') return; if (viewMode !== 'split') return;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const lineHeight = getLineHeight();
const scrollTop = editor.scrollTop; const scrollTop = editor.scrollTop;
const topLine = Math.floor(scrollTop / lineHeight); const topLine = Math.floor(scrollTop / lineHeight);
@@ -547,6 +570,7 @@
updateLineNumbers(); updateLineNumbers();
updateFileSize(); updateFileSize();
cachePreviewPositions(); cachePreviewPositions();
_cachedLineHeight = null; // 内容变化后重新测量行高
}, 150); }, 150);
} }
@@ -1128,7 +1152,6 @@
const tab = getActiveTab(); const tab = getActiveTab();
if (tab) { if (tab) {
tab.isModified = true; tab.isModified = true;
tab.content = editor.value;
setModified(true); setModified(true);
} }
scheduleUpdate(); scheduleUpdate();
@@ -1142,6 +1165,10 @@
if (lineCountCache > 2000) { if (lineCountCache > 2000) {
renderVirtualLineNumbers(lineCountCache); renderVirtualLineNumbers(lineCountCache);
} }
// 搜索高亮跟随编辑器滚动更新位置
if (searchHighlightsOverlay) {
searchHighlightsOverlay.style.top = -editor.scrollTop + 'px';
}
}); });
editor.addEventListener('click', updateCursorPosition); editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition); editor.addEventListener('keyup', updateCursorPosition);
@@ -1179,6 +1206,7 @@
function openSearch(showReplace) { function openSearch(showReplace) {
searchBar.classList.remove('hidden'); searchBar.classList.remove('hidden');
replaceRow.classList.toggle('hidden', !showReplace); replaceRow.classList.toggle('hidden', !showReplace);
btnToggleReplace.classList.toggle('expanded', showReplace);
searchInput.focus(); searchInput.focus();
// Pre-fill with selected text // Pre-fill with selected text
const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd); const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd);
@@ -1190,6 +1218,8 @@
function closeSearch() { function closeSearch() {
searchBar.classList.add('hidden'); searchBar.classList.add('hidden');
replaceRow.classList.add('hidden');
btnToggleReplace.classList.remove('expanded');
clearHighlights(); clearHighlights();
searchMatches = []; searchMatches = [];
searchIndex = -1; searchIndex = -1;
@@ -1258,28 +1288,43 @@
return matches; return matches;
} }
// ===== Search Highlight Cache =====
let cachedLineStarts = null;
let cachedLineStartsContent = null;
// 二分查找:返回 pos 所在行号(O(log N) 替代原来的线性扫描 O(N))
function getLineNumber(pos) {
if (!cachedLineStarts) return 0;
let lo = 0, hi = cachedLineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (cachedLineStarts[mid] <= pos) lo = mid; else hi = mid - 1;
}
return lo;
}
function highlightMatches() { function highlightMatches() {
clearHighlights(); clearHighlights();
if (searchMatches.length === 0) return; if (searchMatches.length === 0) return;
const content = editor.value; const content = editor.value;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const lineHeight = getLineHeight();
const editorStyle = getComputedStyle(editor); const editorStyle = getComputedStyle(editor);
const paddingTop = parseFloat(editorStyle.paddingTop); const paddingTop = parseFloat(editorStyle.paddingTop);
const paddingLeft = parseFloat(editorStyle.paddingLeft); const paddingLeft = parseFloat(editorStyle.paddingLeft);
// Compute line starts for fast lookup // 缓存 lineStarts:内容未变时跳过重建(O(N) → O(1))
const lineStarts = [0]; if (cachedLineStartsContent !== content) {
for (let i = 0; i < content.length; i++) { cachedLineStarts = [0];
if (content.charCodeAt(i) === 10) lineStarts.push(i + 1); for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) cachedLineStarts.push(i + 1);
}
cachedLineStartsContent = content;
} }
const lineStarts = cachedLineStarts;
function posToXY(pos) { function posToXY(pos) {
let line = 0; const line = getLineNumber(pos);
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] > pos) break;
line = i;
}
const col = pos - lineStarts[line]; const col = pos - lineStarts[line];
return { return {
top: line * lineHeight + paddingTop, top: line * lineHeight + paddingTop,
@@ -1289,10 +1334,11 @@
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'search-highlights'; overlay.id = 'search-highlights';
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden;z-index:1;'; overlay.style.cssText = `position:absolute;top:${-editor.scrollTop}px;left:0;right:0;height:${editor.scrollHeight}px;pointer-events:none;overflow:visible;z-index:1;`;
const wrapper = document.getElementById('editor-wrapper'); const wrapper = document.getElementById('editor-wrapper');
wrapper.style.position = 'relative'; wrapper.style.position = 'relative';
wrapper.appendChild(overlay); wrapper.appendChild(overlay);
searchHighlightsOverlay = overlay;
for (let i = 0; i < searchMatches.length; i++) { for (let i = 0; i < searchMatches.length; i++) {
const m = searchMatches[i]; const m = searchMatches[i];
@@ -1309,6 +1355,7 @@
function clearHighlights() { function clearHighlights() {
const old = document.getElementById('search-highlights'); const old = document.getElementById('search-highlights');
if (old) old.remove(); if (old) old.remove();
searchHighlightsOverlay = null;
} }
function scrollToMatch(index) { function scrollToMatch(index) {
@@ -1316,13 +1363,17 @@
searchIndex = index; searchIndex = index;
const m = searchMatches[index]; const m = searchMatches[index];
editor.setSelectionRange(m.start, m.end); editor.setSelectionRange(m.start, m.end);
// Scroll the match into view // 复用缓存的 lineStarts + 二分查找(替代逐字符 O(N) 扫描)
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8; const lineHeight = getLineHeight();
const content = editor.value; if (!cachedLineStarts) {
let line = 0; const content = editor.value;
for (let i = 0; i < m.start; i++) { cachedLineStarts = [0];
if (content.charCodeAt(i) === 10) line++; for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) cachedLineStarts.push(i + 1);
}
cachedLineStartsContent = content;
} }
const line = getLineNumber(m.start);
const matchTop = line * lineHeight; const matchTop = line * lineHeight;
const viewTop = editor.scrollTop; const viewTop = editor.scrollTop;
const viewBottom = viewTop + editor.clientHeight; const viewBottom = viewTop + editor.clientHeight;
@@ -1357,12 +1408,20 @@
function replaceAll() { function replaceAll() {
if (searchMatches.length === 0) return; if (searchMatches.length === 0) return;
const replacement = replaceInput.value; const replacement = replaceInput.value;
// Replace from end to start to preserve positions const content = editor.value;
// 拼接替换后的完整内容
let newContent = '';
let lastEnd = 0;
for (let i = searchMatches.length - 1; i >= 0; i--) { for (let i = searchMatches.length - 1; i >= 0; i--) {
const m = searchMatches[i]; newContent = content.substring(lastEnd, searchMatches[i].start) + replacement + newContent;
editor.setSelectionRange(m.start, m.end); lastEnd = searchMatches[i].end;
document.execCommand('insertText', false, replacement);
} }
newContent = content.substring(0, searchMatches[0].start) + newContent + content.substring(searchMatches[searchMatches.length - 1].end);
// 使用 select + execCommand 实现:一次原子操作进 undo 栈
// Ctrl+Z 可一次性撤销全部替换
editor.focus();
editor.select();
document.execCommand('insertText', false, newContent);
doSearch(); doSearch();
} }
@@ -1627,6 +1686,12 @@
// ===== Search Bar Init ===== // ===== Search Bar Init =====
function initSearchBar() { function initSearchBar() {
searchInput.addEventListener('input', doSearch); searchInput.addEventListener('input', doSearch);
btnToggleReplace.addEventListener('click', () => {
const isVisible = !replaceRow.classList.contains('hidden');
replaceRow.classList.toggle('hidden');
btnToggleReplace.classList.toggle('expanded', !isVisible);
if (!isVisible) replaceInput.focus();
});
btnCase.addEventListener('click', () => toggleSearchOption('case')); btnCase.addEventListener('click', () => toggleSearchOption('case'));
btnRegex.addEventListener('click', () => toggleSearchOption('regex')); btnRegex.addEventListener('click', () => toggleSearchOption('regex'));
btnNext.addEventListener('click', findNext); btnNext.addEventListener('click', findNext);
+9
View File
@@ -886,6 +886,15 @@ html, body {
} }
/* ===== Search & Replace Bar ===== */ /* ===== Search & Replace Bar ===== */
#btn-toggle-replace {
font-size: 10px;
transition: transform 0.15s ease;
}
#btn-toggle-replace.expanded {
transform: rotate(90deg);
}
#search-bar { #search-bar {
background: var(--search-bg); background: var(--search-bg);
border-bottom: 1px solid var(--search-border); border-bottom: 1px solid var(--search-border);