From 9f4e62119a20ddf897f6623d634f7a9bb2c6131c Mon Sep 17 00:00:00 2001 From: OA Hsiao Date: Tue, 23 Jun 2026 17:57:26 +0800 Subject: [PATCH 1/3] feat(analysis): fold non-highlighted lines with row-based virtualization Add a fold toggle (default off, Alt+F opens the log dir) that collapses runs of non-highlighted lines into a full-width fold bar above the next line. Expanding shows a collapse bar above and below the run, and the revealed lines flash once to point out which section opened. Folding is implemented as a row model on top of the virtualized viewer so scroll, bookmarks, nav, find and the minimap stay correct. --- src/renderer/css/styles.css | 67 ++++++++++ src/renderer/i18n/en.json | 3 + src/renderer/i18n/zh.json | 3 + src/renderer/index.html | 4 + src/renderer/js/app.js | 250 ++++++++++++++++++++++++++++++++++-- 5 files changed, 313 insertions(+), 14 deletions(-) diff --git a/src/renderer/css/styles.css b/src/renderer/css/styles.css index 4e7d047..f360a43 100644 --- a/src/renderer/css/styles.css +++ b/src/renderer/css/styles.css @@ -1346,6 +1346,73 @@ body.ana-ruler-dragging .ana-ruler-view { border-radius: 5px; } +/* Fold bar: a full-width separator that stands in for a run of collapsed + (non-highlighted) lines, or sits above/below an expanded run. Its height is + set inline to match the virtualized line height exactly. */ +.ana-foldrow { + display: flex; + align-items: center; + min-width: 100%; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + background: color-mix(in srgb, var(--accent) 5%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--accent) 22%, transparent); +} +.ana-foldrow.foot { + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--accent) 22%, transparent); +} +.ana-foldrow:hover { + background: color-mix(in srgb, var(--accent) 11%, transparent); +} +.ana-fold-bar { + display: inline-flex; + align-items: center; + gap: 5px; + height: 1.35em; + margin-left: 14px; + padding: 0 8px; + border-radius: 5px; + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); + font-size: 0.82em; + font-weight: 700; + line-height: 1; +} +.ana-fold-ic::before { + content: '+'; + font-weight: 800; +} +.ana-foldrow.open .ana-fold-ic::before { + content: '\2212'; /* minus sign */ +} +.ana-fold-n { + font-variant-numeric: tabular-nums; + opacity: 0.95; +} + +/* Toolbar fold toggle active state (mirrors the other ghost toggles). */ +#btnAnaFold.on { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +/* Pulse a just-expanded run's background once, slowly, to point it out. */ +@keyframes anaFoldFlash { + 0% { + background: transparent; + } + 50% { + background: color-mix(in srgb, var(--accent) 34%, transparent); + } + 100% { + background: transparent; + } +} +.ana-line.foldflash { + animation: anaFoldFlash 1.2s ease-in-out; +} + .hl-error { color: #ff6b81; font-weight: 700; diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json index d669ab0..79fad26 100644 --- a/src/renderer/i18n/en.json +++ b/src/renderer/i18n/en.json @@ -74,6 +74,9 @@ "ana.highlight": "Highlight", "ana.hl.auto": "Auto", "ana.hl.none": "Off", + "ana.fold": "Fold", + "ana.fold.title": "Fold lines without highlights, showing only highlighted lines", + "ana.fold.lines": "{n} hidden lines — click to expand", "ana.copy": "Copy", "ana.zoomIn.title": "Zoom in (Ctrl +)", "ana.zoomOut.title": "Zoom out (Ctrl -)", diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json index 7e0e188..32d192b 100644 --- a/src/renderer/i18n/zh.json +++ b/src/renderer/i18n/zh.json @@ -72,6 +72,9 @@ "ana.tab.copied": "已複製路徑", "ana.tab.copyFail": "複製失敗", "ana.highlight": "標記", + "ana.fold": "收折", + "ana.fold.title": "收折未標記的內容,只顯示被標記的行", + "ana.fold.lines": "已收折 {n} 行,點擊展開", "ana.hl.auto": "自動", "ana.hl.none": "關閉", "ana.copy": "複製", diff --git a/src/renderer/index.html b/src/renderer/index.html index 494d565..d9d300f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -262,6 +262,10 @@

檢視

+
0 diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index 53e03a6..62e79db 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -984,6 +984,7 @@ const ANA_HL_KEY = 'm2log_ana_hl'; const ANA_FONT_KEY = 'm2log_ana_font'; const ANA_ROOT_KEY = 'm2log_ana_root'; const ANA_FILE_KEY = 'm2log_ana_file'; +const ANA_FOLD_KEY = 'm2log_ana_fold'; function clampAnaFont(n) { return Number.isFinite(n) ? Math.min(24, Math.max(9, n)) : 12.5; } @@ -994,7 +995,13 @@ const ana = { name: '', lines: null, font: clampAnaFont(parseFloat(localStorage.getItem(ANA_FONT_KEY))), + // Fold (collapse) the runs of lines that carry no highlight, leaving only the + // highlighted lines visible. Default off; persisted (set to '1' to enable). + fold: localStorage.getItem(ANA_FOLD_KEY) === '1', }; +// Which collapsed runs are currently expanded, keyed by the run's first line +// index. Reset whenever a file is (re)rendered. +const anaFold = { open: new Set() }; const anaNav = { markers: [], targets: [], pos: -1, line: -1, levels: {} }; // Bookmarks: `lines` points at the active file's set; `store` keeps one set per // file path so bookmarks survive switching files and coming back (per session). @@ -1054,6 +1061,8 @@ function anaApplyViewPrefs() { el.classList.add('nowrap'); el.style.fontSize = ana.font + 'px'; } + const fb = document.getElementById('btnAnaFold'); + if (fb) fb.classList.toggle('on', ana.fold); } function anaSetFont(px) { @@ -1683,7 +1692,145 @@ function anaHighlightLine(line, rules) { // (nowrap; long lines scroll horizontally), so the total scroll height is // total*lineH and the visible window is derived from scrollTop. Two spacer divs // hold the off-screen height above and below the rendered window. -const anaVirt = { lines: null, rules: null, total: 0, lineH: 19, start: -1, end: -1, scheduled: false }; +// Folding adds a "row" layer on top of the raw lines: the viewer virtualizes +// over `rows` (each row is either a real line or a collapsed-run placeholder), +// not over the lines directly. `lineRow[i]` is the row that renders line i (-1 +// when it is hidden inside a collapsed run); `lineDisplayRow[i]` is the row that +// visually represents line i on screen (its own row, or the fold placeholder of +// its run) and is used for ruler/scroll positioning. `hlSet` holds the indices +// of highlighted (never-folded) lines. +const anaVirt = { + lines: null, + rules: null, + total: 0, + lineH: 19, + start: -1, + end: -1, + scheduled: false, + rows: null, + rowCount: 0, + lineRow: null, + lineDisplayRow: null, + hlSet: null, +}; + +// Build the row model from the current lines + highlight markers + fold state. +// When folding is off (or there are no lines) every line is its own row. When +// on, consecutive non-highlighted lines collapse into a single fold row; runs +// listed in anaFold.open also emit their line rows after the fold header. +function anaBuildRows() { + const total = anaVirt.total; + const rows = []; + const lineRow = total ? new Int32Array(total).fill(-1) : new Int32Array(0); + const dispRow = total ? new Int32Array(total) : new Int32Array(0); + const hl = anaVirt.hlSet; + if (!ana.fold || !hl || !total) { + for (let i = 0; i < total; i += 1) { + lineRow[i] = rows.length; + dispRow[i] = rows.length; + rows.push({ line: i }); + } + } else { + let i = 0; + while (i < total) { + if (hl.has(i)) { + lineRow[i] = rows.length; + dispRow[i] = rows.length; + rows.push({ line: i }); + i += 1; + } else { + const a = i; + while (i < total && !hl.has(i)) i += 1; + const count = i - a; + const open = anaFold.open.has(a); + if (open) { + // Expanded: a collapse bar above and below the revealed lines so the + // run can be re-folded from either end. + rows.push({ fold: true, start: a, count, open: true }); + for (let j = a; j < i; j += 1) { + lineRow[j] = rows.length; + dispRow[j] = rows.length; + rows.push({ line: j }); + } + rows.push({ fold: true, start: a, count, open: true, foot: true }); + } else { + const foldRowIdx = rows.length; + rows.push({ fold: true, start: a, count, open: false }); + for (let j = a; j < i; j += 1) { + dispRow[j] = foldRowIdx; // collapsed: represented by the fold bar + } + } + } + } + } + anaVirt.rows = rows; + anaVirt.rowCount = rows.length; + anaVirt.lineRow = lineRow; + anaVirt.lineDisplayRow = dispRow; +} + +// Expand the collapsed run that contains `idx` (if any) so the line becomes +// visible. Returns true when a rebuild happened. +function anaFoldOpenForLine(idx) { + if (!ana.fold || !anaVirt.hlSet || idx == null || idx < 0 || idx >= anaVirt.total) return false; + if (anaVirt.hlSet.has(idx)) return false; // highlighted lines are never folded + if (anaVirt.lineRow && anaVirt.lineRow[idx] >= 0) return false; // already visible + let a = idx; + while (a > 0 && !anaVirt.hlSet.has(a - 1)) a -= 1; + anaFold.open.add(a); + anaBuildRows(); + return true; +} + +// Rebuild rows + DOM after a fold change, keeping `anchorLine` in view. +function anaFoldApply(anchorLine) { + anaBuildRows(); + anaVirt.start = -1; + anaVirt.end = -1; + const scroller = document.getElementById('anaScroll'); + const lineH = anaVirt.lineH || 19; + if (scroller && anchorLine != null && anchorLine >= 0 && anaVirt.lineDisplayRow) { + const dr = anaVirt.lineDisplayRow[anchorLine]; + if (dr >= 0) { + scroller.scrollTop = Math.max(0, dr * lineH - scroller.clientHeight / 2 + lineH / 2); + } + } + anaVirtRender(); + anaBuildRuler(anaNav.markers, anaVirt.total); + anaUpdateRuler(); +} + +// Flash the lines of a just-expanded run (once, slowly) so the user can see +// which section opened. Best-effort: only the lines currently in the rendered +// window animate; off-screen lines are ignored. +function anaFoldFlashRun(start) { + if (start == null || start < 0 || !anaVirt.hlSet) return; + let end = start; + while (end < anaVirt.total && !anaVirt.hlSet.has(end)) end += 1; + for (let i = start; i < end; i += 1) { + const el = anaLineEl(i); + if (!el) continue; + el.classList.remove('foldflash'); + void el.offsetWidth; // restart the animation if the class lingered + el.classList.add('foldflash'); + } + window.setTimeout(() => { + for (let i = start; i < end; i += 1) { + const el = anaLineEl(i); + if (el) el.classList.remove('foldflash'); + } + }, 1300); +} + +// Toolbar toggle: fold/unfold the non-highlighted runs. +function anaToggleFold() { + ana.fold = !ana.fold; + localStorage.setItem(ANA_FOLD_KEY, ana.fold ? '1' : '0'); + const btn = document.getElementById('btnAnaFold'); + if (btn) btn.classList.toggle('on', ana.fold); + if (ana.fold) anaFold.open.clear(); + if (anaVirt.lines) anaFoldApply(anaFirstVisibleLine()); +} // Measure one rendered line's height (depends on the current font size). function anaMeasureLineH(el) { @@ -1697,22 +1844,25 @@ function anaMeasureLineH(el) { return h || 19; } -// The DOM element for line `idx`, or null if it is outside the rendered window. -// Children layout: [topSpacer, ...windowLines, bottomSpacer]. +// The DOM element for line `idx`, or null if it is outside the rendered window +// or hidden inside a collapsed fold. Children layout: [topSpacer, ...windowRows, +// bottomSpacer]; line `idx` lives at row `lineRow[idx]`. function anaLineEl(idx) { - if (idx == null || idx < anaVirt.start || idx >= anaVirt.end) return null; + if (idx == null || idx < 0) return null; + const r = anaVirt.lineRow && idx < anaVirt.lineRow.length ? anaVirt.lineRow[idx] : idx; + if (r < 0 || r < anaVirt.start || r >= anaVirt.end) return null; const el = document.getElementById('anaViewContent'); - return el ? el.children[1 + (idx - anaVirt.start)] || null : null; + return el ? el.children[1 + (r - anaVirt.start)] || null : null; } -// Render the window of lines visible at the current scroll position. +// Render the window of rows visible at the current scroll position. function anaVirtRender() { anaVirt.scheduled = false; const el = document.getElementById('anaViewContent'); const scroller = document.getElementById('anaScroll'); - if (!el || !scroller || !anaVirt.lines) return; + if (!el || !scroller || !anaVirt.lines || !anaVirt.rows) return; const lineH = anaVirt.lineH || 19; - const total = anaVirt.total; + const total = anaVirt.rowCount; const buffer = 40; const firstVis = Math.floor(scroller.scrollTop / lineH); const lastVis = Math.ceil((scroller.scrollTop + scroller.clientHeight) / lineH); @@ -1734,12 +1884,27 @@ function anaVirtRender() { anaVirt.start = start; anaVirt.end = end; + const rows = anaVirt.rows; const lines = anaVirt.lines; const rules = anaVirt.rules; const cur = anaBm.current; const bm = anaBm.lines; let html = '
'; - for (let i = start; i < end; i += 1) { + for (let r = start; r < end; r += 1) { + const row = rows[r]; + if (row.fold) { + const title = row.open + ? t('ana.fold', '收折') + : t('ana.fold.lines', '{n} hidden lines').replace('{n}', row.count); + const label = row.open ? escapeHtml(t('ana.fold', '收折')) : String(row.count); + const cls = 'ana-foldrow' + (row.open ? ' open' : '') + (row.foot ? ' foot' : ''); + html += + '
' + + '' + + '' + label + '
'; + continue; + } + const i = row.line; const res = anaHighlightLine(lines[i], rules); const lvl = res.tint ? ' lvl-' + res.tint : ''; const b = bm.has(i) ? ' bookmarked' : ''; @@ -1789,6 +1954,9 @@ function anaRenderContent(text, rules) { anaVirt.total = total; anaVirt.start = -1; anaVirt.end = -1; + // Highlighted lines never fold; collapse all non-highlighted runs by default. + anaVirt.hlSet = new Set(markers.map((m) => m.i)); + anaFold.open.clear(); el.classList.add('nowrap'); // virtualization needs a fixed line height el.innerHTML = ''; @@ -1797,6 +1965,7 @@ function anaRenderContent(text, rules) { if (scroller) scroller.scrollTop = 0; anaNav.markers = markers; + anaBuildRows(); anaVirtRender(); anaBuildRuler(markers, total); @@ -1813,16 +1982,25 @@ function anaBuildRuler(markers, total) { const ticks = document.getElementById('anaRulerTicks'); if (ticks) { let html = ''; + // Position ticks by their on-screen row (so they line up with the scrollbar + // even when folding changes how many rows precede a line). + const rc = anaVirt.rowCount || total; + const dispRow = anaVirt.lineDisplayRow; + const fracOf = (i) => { + if (!rc) return 0; + const r = dispRow && i < dispRow.length ? dispRow[i] : i; + return ((r >= 0 ? r : i) / rc) * 100; + }; if (total > 0) { (markers || []).forEach((m) => { // Hide ticks for deselected levels so the minimap matches the content. if (anaNav.levels[m.level] === false) return; - const top = (m.i / total) * 100; + const top = fracOf(m.i); const c = anaColorForLevel(m.level); html += `
`; }); anaBm.lines.forEach((i) => { - const top = (i / total) * 100; + const top = fracOf(i); html += `
`; }); } @@ -1859,9 +2037,16 @@ function anaUpdateRuler() { function anaScrollToLine(idx, opts) { const scroller = document.getElementById('anaScroll'); if (!scroller || idx == null || idx < 0) return; + // If the target sits inside a collapsed fold, expand it first so it has a row. + if (anaFoldOpenForLine(idx)) { + anaBuildRuler(anaNav.markers, anaVirt.total); + } const lineH = anaVirt.lineH || 19; - scroller.scrollTop = Math.max(0, idx * lineH - scroller.clientHeight / 2 + lineH / 2); + const r = anaVirt.lineDisplayRow && idx < anaVirt.lineDisplayRow.length ? anaVirt.lineDisplayRow[idx] : idx; + const rowIdx = r >= 0 ? r : idx; + scroller.scrollTop = Math.max(0, rowIdx * lineH - scroller.clientHeight / 2 + lineH / 2); anaVirtRender(); // render the window at the new position so the row exists + anaUpdateRuler(); if (opts && opts.noFlash) return; const row = anaLineEl(idx); if (!row) return; @@ -2145,8 +2330,21 @@ function anaFirstVisibleLine() { const scroller = document.getElementById('anaScroll'); if (!scroller || !anaVirt.total) return 0; const lineH = anaVirt.lineH || 19; - const i = Math.floor(scroller.scrollTop / lineH); - return Math.max(0, Math.min(anaVirt.total - 1, i)); + const rowIdx = Math.floor(scroller.scrollTop / lineH); + const rows = anaVirt.rows; + if (rows && rows.length) { + const clamped = Math.max(0, Math.min(rows.length - 1, rowIdx)); + // Walk forward to the first row that maps to a real line (skip fold headers). + for (let r = clamped; r < rows.length; r += 1) { + if (rows[r].fold) { + if (typeof rows[r].start === 'number') return rows[r].start; + } else { + return rows[r].line; + } + } + return rows[clamped].fold ? rows[clamped].start : rows[clamped].line; + } + return Math.max(0, Math.min(anaVirt.total - 1, rowIdx)); } // Mark a line as the "current" one (reference for adding/navigating bookmarks). @@ -2276,6 +2474,8 @@ $('#btnAnaOpen').addEventListener('click', () => { if (zin) zin.addEventListener('click', () => anaSetFont(ana.font + 1)); const zout = document.getElementById('btnAnaZoomOut'); if (zout) zout.addEventListener('click', () => anaSetFont(ana.font - 1)); + const fold = document.getElementById('btnAnaFold'); + if (fold) fold.addEventListener('click', anaToggleFold); })(); // Ctrl +/-/0 zooms the viewer font while the analysis view is active. document.addEventListener('keydown', (e) => { @@ -2293,6 +2493,14 @@ document.addEventListener('keydown', (e) => { anaSetFont(12.5); } }); +// Alt+F opens the current log directory while the analysis view is active. +document.addEventListener('keydown', (e) => { + if (!e.altKey || (e.key !== 'f' && e.key !== 'F')) return; + const av = document.getElementById('view-analysis'); + if (!av || !av.classList.contains('active')) return; + e.preventDefault(); + if (ana.root) window.m2log.openFolder(ana.root); +}); $('#anaHlSelect').addEventListener('change', async (e) => { ana.hl = e.target.value || 'auto'; localStorage.setItem(ANA_HL_KEY, ana.hl); @@ -2435,6 +2643,20 @@ if (document.getElementById('anaLevels')) { } if (document.getElementById('anaViewContent')) { document.getElementById('anaViewContent').addEventListener('click', (e) => { + // Toggle a collapsed run when its fold box is clicked. + const foldRow = e.target.closest('.ana-foldrow'); + if (foldRow) { + const start = parseInt(foldRow.dataset.fold, 10); + if (!Number.isNaN(start)) { + const wasOpen = anaFold.open.has(start); + if (wasOpen) anaFold.open.delete(start); + else anaFold.open.add(start); + anaFoldApply(start); + // Briefly flash the just-revealed lines so it's clear which run opened. + if (!wasOpen) anaFoldFlashRun(start); + } + return; + } // Keep keyboard focus on the scroller (which survives the virtualized // window rebuilds) so PgUp/PgDn/Home/End keep working. Skip when the user // just made a text selection so we don't disturb it. From d968723ddffb873703e5ad597540175e12496aec Mon Sep 17 00:00:00 2001 From: OA Hsiao Date: Tue, 23 Jun 2026 18:03:47 +0800 Subject: [PATCH 2/3] feat(analysis): add fold jump arrows and sync fold with level toggles Expanded fold bars now carry a navigation arrow: the top bar jumps to the bottom of the section, the bottom bar jumps to the top. Toggling a highlight level off now also folds its lines, since only enabled levels count as highlighted. --- src/renderer/css/styles.css | 15 +++++++++++++++ src/renderer/i18n/en.json | 2 ++ src/renderer/i18n/zh.json | 2 ++ src/renderer/js/app.js | 34 +++++++++++++++++++++++++++++++--- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/renderer/css/styles.css b/src/renderer/css/styles.css index f360a43..f55174f 100644 --- a/src/renderer/css/styles.css +++ b/src/renderer/css/styles.css @@ -1390,6 +1390,21 @@ body.ana-ruler-dragging .ana-ruler-view { font-variant-numeric: tabular-nums; opacity: 0.95; } +.ana-fold-jump { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 8px; + padding: 1px 4px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--accent); + cursor: pointer; +} +.ana-fold-jump:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent); +} /* Toolbar fold toggle active state (mirrors the other ghost toggles). */ #btnAnaFold.on { diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json index 79fad26..896c46a 100644 --- a/src/renderer/i18n/en.json +++ b/src/renderer/i18n/en.json @@ -77,6 +77,8 @@ "ana.fold": "Fold", "ana.fold.title": "Fold lines without highlights, showing only highlighted lines", "ana.fold.lines": "{n} hidden lines — click to expand", + "ana.fold.toBottom": "Jump to the bottom of this section", + "ana.fold.toTop": "Jump to the top of this section", "ana.copy": "Copy", "ana.zoomIn.title": "Zoom in (Ctrl +)", "ana.zoomOut.title": "Zoom out (Ctrl -)", diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json index 32d192b..24c2082 100644 --- a/src/renderer/i18n/zh.json +++ b/src/renderer/i18n/zh.json @@ -75,6 +75,8 @@ "ana.fold": "收折", "ana.fold.title": "收折未標記的內容,只顯示被標記的行", "ana.fold.lines": "已收折 {n} 行,點擊展開", + "ana.fold.toBottom": "跳到此段最下練", + "ana.fold.toTop": "跳到此段最上練", "ana.hl.auto": "自動", "ana.hl.none": "關閉", "ana.copy": "複製", diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index 62e79db..b2ed48d 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -1898,10 +1898,24 @@ function anaVirtRender() { : t('ana.fold.lines', '{n} hidden lines').replace('{n}', row.count); const label = row.open ? escapeHtml(t('ana.fold', '收折')) : String(row.count); const cls = 'ana-foldrow' + (row.open ? ' open' : '') + (row.foot ? ' foot' : ''); + // When expanded, the top bar gets a "jump to the bottom of this section" + // arrow and the bottom bar a "jump to the top" arrow. + let jump = ''; + if (row.open) { + const arrow = row.foot + ? '' + : ''; + const jt = row.foot + ? t('ana.fold.toTop', '跳到此段最上緣') + : t('ana.fold.toBottom', '跳到此段最下緣'); + jump = + ''; + } html += - '
' + + '
' + '' + - '' + label + '
'; + '' + label + '' + jump + '
'; continue; } const i = row.line; @@ -1955,7 +1969,11 @@ function anaRenderContent(text, rules) { anaVirt.start = -1; anaVirt.end = -1; // Highlighted lines never fold; collapse all non-highlighted runs by default. - anaVirt.hlSet = new Set(markers.map((m) => m.i)); + // Only currently-enabled levels count as "highlighted", so toggling a level + // off folds its lines too. + anaVirt.hlSet = new Set( + markers.filter((m) => anaNav.levels[m.level] !== false).map((m) => m.i) + ); anaFold.open.clear(); el.classList.add('nowrap'); // virtualization needs a fixed line height @@ -2648,6 +2666,16 @@ if (document.getElementById('anaViewContent')) { if (foldRow) { const start = parseInt(foldRow.dataset.fold, 10); if (!Number.isNaN(start)) { + // The jump arrow on an expanded bar navigates within the run instead of + // collapsing it: down → bottom edge, up → top edge. + const jumpBtn = e.target.closest('.ana-fold-jump'); + if (jumpBtn) { + const count = parseInt(foldRow.dataset.count, 10) || 1; + const target = jumpBtn.dataset.jump === 'up' ? start : start + count - 1; + anaScrollToLine(target); + anaSetCurrentLine(target); + return; + } const wasOpen = anaFold.open.has(start); if (wasOpen) anaFold.open.delete(start); else anaFold.open.add(start); From de4b232873b819d104a6efbe4f57e8bc8c337e41 Mon Sep 17 00:00:00 2001 From: OA Hsiao Date: Tue, 23 Jun 2026 18:06:42 +0800 Subject: [PATCH 3/3] feat(analysis): flash the fold bar when a run is collapsed Mirror the expand flash: collapsing a run now briefly pulses the resulting fold bar so it's clear where the hidden lines went. --- src/renderer/css/styles.css | 3 +++ src/renderer/js/app.js | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/renderer/css/styles.css b/src/renderer/css/styles.css index f55174f..c045c48 100644 --- a/src/renderer/css/styles.css +++ b/src/renderer/css/styles.css @@ -1427,6 +1427,9 @@ body.ana-ruler-dragging .ana-ruler-view { .ana-line.foldflash { animation: anaFoldFlash 1.2s ease-in-out; } +.ana-foldrow.foldflash { + animation: anaFoldFlash 1.2s ease-in-out; +} .hl-error { color: #ff6b81; diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index b2ed48d..16b4cd2 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -1822,6 +1822,19 @@ function anaFoldFlashRun(start) { }, 1300); } +// Flash the collapsed fold bar of a just-folded run, so it's clear where the +// hidden lines went. +function anaFoldFlashBar(start) { + const el = document.getElementById('anaViewContent'); + if (!el) return; + const bar = el.querySelector('.ana-foldrow[data-fold="' + start + '"]'); + if (!bar) return; + bar.classList.remove('foldflash'); + void bar.offsetWidth; // restart the animation if the class lingered + bar.classList.add('foldflash'); + window.setTimeout(() => bar.classList.remove('foldflash'), 1300); +} + // Toolbar toggle: fold/unfold the non-highlighted runs. function anaToggleFold() { ana.fold = !ana.fold; @@ -2680,8 +2693,10 @@ if (document.getElementById('anaViewContent')) { if (wasOpen) anaFold.open.delete(start); else anaFold.open.add(start); anaFoldApply(start); - // Briefly flash the just-revealed lines so it's clear which run opened. - if (!wasOpen) anaFoldFlashRun(start); + // Flash the affected area: the revealed lines when expanding, or the new + // fold bar when collapsing. + if (wasOpen) anaFoldFlashBar(start); + else anaFoldFlashRun(start); } return; }