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
+90 -25
View File
@@ -38,6 +38,7 @@
const btnSearchClose = document.getElementById('btn-search-close');
const btnReplace = document.getElementById('btn-replace');
const btnReplaceAll = document.getElementById('btn-replace-all');
const btnToggleReplace = document.getElementById('btn-toggle-replace');
// Buttons
const btnOpen = document.getElementById('btn-open');
@@ -69,6 +70,7 @@
let searchIndex = -1;
let searchCaseSensitive = false;
let searchUseRegex = false;
let searchHighlightsOverlay = null; // 当前搜索高亮 overlay 元素引用
// ===== Character Width Measurement (#1) =====
const _charWidthCanvas = document.createElement('canvas');
@@ -84,6 +86,27 @@
// Recalculate on font/theme change
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) =====
function showToast(message, duration) {
duration = duration || 3000;
@@ -348,7 +371,7 @@
// 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
const arr = new Array(count);
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('');
}
@@ -356,7 +379,7 @@
// 虚拟化行号:只渲染当前可视区域附近的行号
function renderVirtualLineNumbers(totalLines) {
const lineHeight = 20.8; // 与 CSS line-height 一致
const lineHeight = getLineHeight();
const scrollTop = editor.scrollTop;
const viewportHeight = editor.clientHeight;
const buffer = 20; // 上下缓冲行数
@@ -518,7 +541,7 @@
function syncScroll() {
if (viewMode !== 'split') return;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const lineHeight = getLineHeight();
const scrollTop = editor.scrollTop;
const topLine = Math.floor(scrollTop / lineHeight);
@@ -547,6 +570,7 @@
updateLineNumbers();
updateFileSize();
cachePreviewPositions();
_cachedLineHeight = null; // 内容变化后重新测量行高
}, 150);
}
@@ -1128,7 +1152,6 @@
const tab = getActiveTab();
if (tab) {
tab.isModified = true;
tab.content = editor.value;
setModified(true);
}
scheduleUpdate();
@@ -1142,6 +1165,10 @@
if (lineCountCache > 2000) {
renderVirtualLineNumbers(lineCountCache);
}
// 搜索高亮跟随编辑器滚动更新位置
if (searchHighlightsOverlay) {
searchHighlightsOverlay.style.top = -editor.scrollTop + 'px';
}
});
editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition);
@@ -1179,6 +1206,7 @@
function openSearch(showReplace) {
searchBar.classList.remove('hidden');
replaceRow.classList.toggle('hidden', !showReplace);
btnToggleReplace.classList.toggle('expanded', showReplace);
searchInput.focus();
// Pre-fill with selected text
const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd);
@@ -1190,6 +1218,8 @@
function closeSearch() {
searchBar.classList.add('hidden');
replaceRow.classList.add('hidden');
btnToggleReplace.classList.remove('expanded');
clearHighlights();
searchMatches = [];
searchIndex = -1;
@@ -1258,28 +1288,43 @@
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() {
clearHighlights();
if (searchMatches.length === 0) return;
const content = editor.value;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const lineHeight = getLineHeight();
const editorStyle = getComputedStyle(editor);
const paddingTop = parseFloat(editorStyle.paddingTop);
const paddingLeft = parseFloat(editorStyle.paddingLeft);
// Compute line starts for fast lookup
const lineStarts = [0];
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) lineStarts.push(i + 1);
// 缓存 lineStarts:内容未变时跳过重建(O(N) → O(1))
if (cachedLineStartsContent !== content) {
cachedLineStarts = [0];
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) {
let line = 0;
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] > pos) break;
line = i;
}
const line = getLineNumber(pos);
const col = pos - lineStarts[line];
return {
top: line * lineHeight + paddingTop,
@@ -1289,10 +1334,11 @@
const overlay = document.createElement('div');
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');
wrapper.style.position = 'relative';
wrapper.appendChild(overlay);
searchHighlightsOverlay = overlay;
for (let i = 0; i < searchMatches.length; i++) {
const m = searchMatches[i];
@@ -1309,6 +1355,7 @@
function clearHighlights() {
const old = document.getElementById('search-highlights');
if (old) old.remove();
searchHighlightsOverlay = null;
}
function scrollToMatch(index) {
@@ -1316,13 +1363,17 @@
searchIndex = index;
const m = searchMatches[index];
editor.setSelectionRange(m.start, m.end);
// Scroll the match into view
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const content = editor.value;
let line = 0;
for (let i = 0; i < m.start; i++) {
if (content.charCodeAt(i) === 10) line++;
// 复用缓存的 lineStarts + 二分查找(替代逐字符 O(N) 扫描)
const lineHeight = getLineHeight();
if (!cachedLineStarts) {
const content = editor.value;
cachedLineStarts = [0];
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 viewTop = editor.scrollTop;
const viewBottom = viewTop + editor.clientHeight;
@@ -1357,12 +1408,20 @@
function replaceAll() {
if (searchMatches.length === 0) return;
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--) {
const m = searchMatches[i];
editor.setSelectionRange(m.start, m.end);
document.execCommand('insertText', false, replacement);
newContent = content.substring(lastEnd, searchMatches[i].start) + replacement + newContent;
lastEnd = searchMatches[i].end;
}
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();
}
@@ -1627,6 +1686,12 @@
// ===== Search Bar Init =====
function initSearchBar() {
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'));
btnRegex.addEventListener('click', () => toggleSearchOption('regex'));
btnNext.addEventListener('click', findNext);