diff --git a/src/renderer/css/styles.css b/src/renderer/css/styles.css
index c045c48..2a0aee8 100644
--- a/src/renderer/css/styles.css
+++ b/src/renderer/css/styles.css
@@ -1120,6 +1120,61 @@ body.ana-ruler-dragging .ana-ruler-view {
background: rgba(130, 150, 210, 0.55);
}
+/* Floating "jump to this expanded run's top/bottom edge" buttons. They sit at
+ the right edge, vertically centred just left of the minimap, and only appear
+ while you browse inside an expanded fold whose edge is scrolled off-screen. */
+.ana-fold-edges {
+ position: absolute;
+ right: 44px;
+ top: 50%;
+ z-index: 6;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-50%) translateX(6px);
+ transition: opacity 0.18s ease, transform 0.18s ease;
+}
+.ana-fold-edges.show {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(-50%) translateX(0);
+}
+.ana-fold-edge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--panel) 86%, transparent);
+ color: var(--accent);
+ cursor: pointer;
+ box-shadow: 0 2px 9px rgba(0, 0, 0, 0.28);
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease;
+}
+.ana-fold-edge:hover {
+ background: color-mix(in srgb, var(--accent) 20%, var(--panel));
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+}
+.ana-fold-edge:active {
+ transform: scale(0.9);
+}
+.ana-fold-edge:disabled {
+ opacity: 0.32;
+ cursor: default;
+ box-shadow: none;
+}
+.ana-fold-edge:disabled:hover {
+ background: color-mix(in srgb, var(--panel) 86%, transparent);
+ border-color: var(--border);
+}
+
/* ---------- In-viewer search (Ctrl+F) ---------- */
.ana-find {
position: absolute;
@@ -1412,23 +1467,49 @@ body.ana-ruler-dragging .ana-ruler-view {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
-/* Pulse a just-expanded run's background once, slowly, to point it out. */
-@keyframes anaFoldFlash {
+/* Breathing-light cue: a single quick, smooth swell of the accent wash that
+ points out a just-folded/expanded run. The brightness follows a sine profile
+ (glides up, lingers softly at the peak, eases back down) and uses `linear`
+ timing so the curve comes purely from these stops — a crisp breath, not a
+ blink. */
+@keyframes anaFoldBreath {
0% {
background: transparent;
}
+ 10% {
+ background: color-mix(in srgb, var(--accent) 9%, transparent);
+ }
+ 20% {
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
+ }
+ 30% {
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
+ }
+ 40% {
+ background: color-mix(in srgb, var(--accent) 29%, transparent);
+ }
50% {
- background: color-mix(in srgb, var(--accent) 34%, transparent);
+ background: color-mix(in srgb, var(--accent) 32%, transparent);
+ }
+ 60% {
+ background: color-mix(in srgb, var(--accent) 29%, transparent);
+ }
+ 70% {
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
+ }
+ 80% {
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
+ }
+ 90% {
+ background: color-mix(in srgb, var(--accent) 9%, transparent);
}
100% {
background: transparent;
}
}
-.ana-line.foldflash {
- animation: anaFoldFlash 1.2s ease-in-out;
-}
+.ana-line.foldflash,
.ana-foldrow.foldflash {
- animation: anaFoldFlash 1.2s ease-in-out;
+ animation: anaFoldBreath 0.9s linear;
}
.hl-error {
diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json
index 24c2082..2989128 100644
--- a/src/renderer/i18n/zh.json
+++ b/src/renderer/i18n/zh.json
@@ -75,8 +75,8 @@
"ana.fold": "收折",
"ana.fold.title": "收折未標記的內容,只顯示被標記的行",
"ana.fold.lines": "已收折 {n} 行,點擊展開",
- "ana.fold.toBottom": "跳到此段最下練",
- "ana.fold.toTop": "跳到此段最上練",
+ "ana.fold.toBottom": "跳到此段最下緣",
+ "ana.fold.toTop": "跳到此段最上緣",
"ana.hl.auto": "自動",
"ana.hl.none": "關閉",
"ana.copy": "複製",
diff --git a/src/renderer/index.html b/src/renderer/index.html
index d9d300f..6ef145a 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -305,6 +305,14 @@
檢視
+
diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js
index 16b4cd2..826960b 100644
--- a/src/renderer/js/app.js
+++ b/src/renderer/js/app.js
@@ -1002,6 +1002,9 @@ const ana = {
// 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() };
+// The expanded run currently under the viewport (start/end line indices) that
+// the floating edge-jump buttons target; {-1, -1} when none is in view.
+const anaFoldEdge = { start: -1, end: -1 };
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).
@@ -1708,6 +1711,7 @@ const anaVirt = {
end: -1,
scheduled: false,
rows: null,
+ runs: null,
rowCount: 0,
lineRow: null,
lineDisplayRow: null,
@@ -1721,6 +1725,7 @@ const anaVirt = {
function anaBuildRows() {
const total = anaVirt.total;
const rows = [];
+ const runs = [];
const lineRow = total ? new Int32Array(total).fill(-1) : new Int32Array(0);
const dispRow = total ? new Int32Array(total) : new Int32Array(0);
const hl = anaVirt.hlSet;
@@ -1742,6 +1747,7 @@ function anaBuildRows() {
const a = i;
while (i < total && !hl.has(i)) i += 1;
const count = i - a;
+ runs.push({ start: a, end: i - 1 });
const open = anaFold.open.has(a);
if (open) {
// Expanded: a collapse bar above and below the revealed lines so the
@@ -1765,6 +1771,7 @@ function anaBuildRows() {
}
anaVirt.rows = rows;
anaVirt.rowCount = rows.length;
+ anaVirt.runs = runs;
anaVirt.lineRow = lineRow;
anaVirt.lineDisplayRow = dispRow;
}
@@ -1798,11 +1805,83 @@ function anaFoldApply(anchorLine) {
anaVirtRender();
anaBuildRuler(anaNav.markers, anaVirt.total);
anaUpdateRuler();
+ anaUpdateFoldEdges();
}
-// 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.
+// Line index -> its non-highlighted run {start, end} (binary search over the
+// run table cached by anaBuildRows), or null when the line is highlighted.
+function anaRunAtLine(idx) {
+ const runs = anaVirt.runs;
+ if (!runs || !runs.length || idx == null || idx < 0) return null;
+ let lo = 0;
+ let hi = runs.length - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1;
+ const r = runs[mid];
+ if (idx < r.start) hi = mid - 1;
+ else if (idx > r.end) lo = mid + 1;
+ else return r;
+ }
+ return null;
+}
+
+// The expanded (currently open) run that contains line `idx`, or null.
+function anaOpenRunAt(idx) {
+ if (!ana.fold) return null;
+ const r = anaRunAtLine(idx);
+ return r && anaFold.open.has(r.start) ? r : null;
+}
+
+// The expanded run filling the viewport now: prefer the line at the vertical
+// centre (where the buttons sit), fall back to the first visible line.
+function anaCurrentOpenRun() {
+ if (!ana.fold || !anaVirt.rows || !anaVirt.rowCount) return null;
+ const scroller = document.getElementById('anaScroll');
+ if (!scroller) return null;
+ const lineH = anaVirt.lineH || 19;
+ const rows = anaVirt.rows;
+ const lineAtPx = (px) => {
+ const r = Math.max(0, Math.min(rows.length - 1, Math.floor(px / lineH)));
+ const row = rows[r];
+ return row.fold ? row.start : row.line;
+ };
+ const mid = lineAtPx(scroller.scrollTop + scroller.clientHeight / 2);
+ return anaOpenRunAt(mid) || anaOpenRunAt(anaFirstVisibleLine());
+}
+
+// Show the floating top/bottom edge-jump buttons while an expanded run is in
+// view and an edge is off-screen; disable the button whose edge is already
+// visible. Remembers the target run for the click handlers.
+function anaUpdateFoldEdges() {
+ const box = document.getElementById('anaFoldEdges');
+ if (!box) return;
+ const topBtn = document.getElementById('btnAnaFoldTop');
+ const bottomBtn = document.getElementById('btnAnaFoldBottom');
+ const run = anaCurrentOpenRun();
+ let upUseful = false;
+ let downUseful = false;
+ if (run) {
+ const scroller = document.getElementById('anaScroll');
+ const lineH = anaVirt.lineH || 19;
+ const lr = anaVirt.lineRow;
+ const topRow = lr && run.start < lr.length ? lr[run.start] : -1;
+ const botRow = lr && run.end < lr.length ? lr[run.end] : -1;
+ const firstVis = Math.floor(scroller.scrollTop / lineH);
+ const lastVis = Math.ceil((scroller.scrollTop + scroller.clientHeight) / lineH);
+ upUseful = topRow >= 0 && topRow < firstVis;
+ downUseful = botRow >= 0 && botRow > lastVis;
+ }
+ const show = upUseful || downUseful;
+ anaFoldEdge.start = show ? run.start : -1;
+ anaFoldEdge.end = show ? run.end : -1;
+ if (topBtn) topBtn.disabled = !upUseful;
+ if (bottomBtn) bottomBtn.disabled = !downUseful;
+ box.classList.toggle('show', show);
+}
+
+// Breathe the lines of a just-expanded run (a gentle accent pulse) 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;
@@ -1819,10 +1898,10 @@ function anaFoldFlashRun(start) {
const el = anaLineEl(i);
if (el) el.classList.remove('foldflash');
}
- }, 1300);
+ }, 1100);
}
-// Flash the collapsed fold bar of a just-folded run, so it's clear where the
+// Breathe 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');
@@ -1832,7 +1911,7 @@ function anaFoldFlashBar(start) {
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);
+ window.setTimeout(() => bar.classList.remove('foldflash'), 1100);
}
// Toolbar toggle: fold/unfold the non-highlighted runs.
@@ -2005,6 +2084,7 @@ function anaRenderContent(text, rules) {
anaNavRebuild();
// Rebuild search highlights (e.g. after a highlight-type change) without moving.
if (anaFind.q) anaFindRun(anaFind.q, { keepPos: true, noScroll: true });
+ anaUpdateFoldEdges();
}
@@ -2078,6 +2158,7 @@ function anaScrollToLine(idx, opts) {
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();
+ anaUpdateFoldEdges();
if (opts && opts.noFlash) return;
const row = anaLineEl(idx);
if (!row) return;
@@ -2652,12 +2733,29 @@ function anaUpdateRulerThrottled() {
anaRulerRaf = 0;
anaVirtRender();
anaUpdateRuler();
+ anaUpdateFoldEdges();
});
}
if (document.getElementById('anaScroll')) {
document.getElementById('anaScroll').addEventListener('scroll', anaUpdateRulerThrottled, { passive: true });
}
window.addEventListener('resize', anaUpdateRulerThrottled);
+// Floating edge-jump buttons: hop to the top / bottom edge of the expanded run
+// currently in view (its fold bars may be scrolled off-screen).
+if (document.getElementById('btnAnaFoldTop')) {
+ document.getElementById('btnAnaFoldTop').addEventListener('click', () => {
+ if (anaFoldEdge.start < 0) return;
+ anaScrollToLine(anaFoldEdge.start);
+ anaSetCurrentLine(anaFoldEdge.start);
+ });
+}
+if (document.getElementById('btnAnaFoldBottom')) {
+ document.getElementById('btnAnaFoldBottom').addEventListener('click', () => {
+ if (anaFoldEdge.end < 0) return;
+ anaScrollToLine(anaFoldEdge.end);
+ anaSetCurrentLine(anaFoldEdge.end);
+ });
+}
$('#btnAnaPrev').addEventListener('click', () => anaNavGo(-1));
$('#btnAnaNext').addEventListener('click', () => anaNavGo(1));
$('#chipBm').addEventListener('click', () => anaBmGo(1));