fix: 搜索高亮跟随滚动同步 + 行号动态行高测量修复 v1.2.1
This commit is contained in:
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+88
-23
@@ -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) {
|
||||||
|
cachedLineStarts = [0];
|
||||||
for (let i = 0; i < content.length; i++) {
|
for (let i = 0; i < content.length; i++) {
|
||||||
if (content.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
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();
|
||||||
|
if (!cachedLineStarts) {
|
||||||
const content = editor.value;
|
const content = editor.value;
|
||||||
let line = 0;
|
cachedLineStarts = [0];
|
||||||
for (let i = 0; i < m.start; i++) {
|
for (let i = 0; i < content.length; i++) {
|
||||||
if (content.charCodeAt(i) === 10) line++;
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user