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));