+
diff --git a/renderer/renderer.js b/renderer/renderer.js
index b5e0590..9df60d0 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -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] = '
' + (i + 1) + '
';
+ arr[i] = '
' + (i + 1) + '
';
}
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);
diff --git a/renderer/style.css b/renderer/style.css
index 3b60d37..0de275d 100644
--- a/renderer/style.css
+++ b/renderer/style.css
@@ -886,6 +886,15 @@ html, body {
}
/* ===== Search & Replace Bar ===== */
+#btn-toggle-replace {
+ font-size: 10px;
+ transition: transform 0.15s ease;
+}
+
+#btn-toggle-replace.expanded {
+ transform: rotate(90deg);
+}
+
#search-bar {
background: var(--search-bg);
border-bottom: 1px solid var(--search-border);