fix: 搜索高亮跟随滚动同步 + 行号动态行高测量修复 v1.2.1
This commit is contained in:
+90
-25
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user