feat(p0.5): continuous-single navigation math (current-page + scroll clamp)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Extends PageStackMetrics with the navigation geometry continuous-single needs: maxScrollExtent (last page bottom rests at viewport bottom, never negative), clampScroll, and dominantPageAt — the page covering most of the viewport, which drives the page-number indicator + thumbnail-grid highlight + jump-to-page (F4). Pure; clamps past both ends; 0 for empty documents. flutter analyze lib/editor clean; 129/129 tests (+6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,49 @@ class PageStackMetrics {
|
|||||||
return PageWindow(first, last);
|
return PageWindow(first, last);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum scroll offset so the last page bottom rests at the viewport
|
||||||
|
/// bottom (never negative — a document shorter than the viewport can't
|
||||||
|
/// scroll).
|
||||||
|
double maxScrollExtent(double viewportExtent) {
|
||||||
|
final max = totalExtent - viewportExtent;
|
||||||
|
return max > 0 ? max : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps [scrollOffset] into the legal `[0, maxScrollExtent]` range.
|
||||||
|
double clampScroll(double scrollOffset, double viewportExtent) {
|
||||||
|
final max = maxScrollExtent(viewportExtent);
|
||||||
|
if (scrollOffset < 0) return 0.0;
|
||||||
|
return scrollOffset > max ? max : scrollOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "current" page for a scroll position: the page covering the LARGEST
|
||||||
|
/// portion of the viewport `[scrollOffset, scrollOffset + viewportExtent)`.
|
||||||
|
/// Drives the page-number indicator + thumbnail-grid highlight (F4). Returns
|
||||||
|
/// 0 for an empty document.
|
||||||
|
int dominantPageAt(double scrollOffset, double viewportExtent) {
|
||||||
|
if (_heights.isEmpty) return 0;
|
||||||
|
final window = visibleRange(scrollOffset, viewportExtent);
|
||||||
|
if (window.isEmpty) {
|
||||||
|
// Past the end / before the start → clamp to nearest real page.
|
||||||
|
return scrollOffset <= 0 ? 0 : pageCount - 1;
|
||||||
|
}
|
||||||
|
final viewTop = scrollOffset;
|
||||||
|
final viewBottom = scrollOffset + viewportExtent;
|
||||||
|
var best = window.first;
|
||||||
|
var bestOverlap = -1.0;
|
||||||
|
for (var i = window.first; i <= window.last; i++) {
|
||||||
|
final top = _tops[i];
|
||||||
|
final bottom = top + _heights[i];
|
||||||
|
final overlap =
|
||||||
|
math.min(bottom, viewBottom) - math.max(top, viewTop);
|
||||||
|
if (overlap > bestOverlap) {
|
||||||
|
bestOverlap = overlap;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/// Lowest index whose band bottom is strictly after [y] (i.e. the first page
|
/// Lowest index whose band bottom is strictly after [y] (i.e. the first page
|
||||||
/// that the window's top edge does not sit fully below).
|
/// that the window's top edge does not sit fully below).
|
||||||
int _firstIntersecting(double y) {
|
int _firstIntersecting(double y) {
|
||||||
|
|||||||
@@ -91,6 +91,47 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('navigation math', () {
|
||||||
|
final m = PageStackMetrics(pageHeights: List.filled(10, 100)); // 0..1000
|
||||||
|
|
||||||
|
test('maxScrollExtent leaves the last page bottom at the viewport bottom',
|
||||||
|
() {
|
||||||
|
expect(m.maxScrollExtent(300), 700); // 1000 - 300
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxScrollExtent is 0 when content is shorter than the viewport', () {
|
||||||
|
final short = PageStackMetrics(pageHeights: [100]);
|
||||||
|
expect(short.maxScrollExtent(800), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampScroll keeps offset within [0, maxScrollExtent]', () {
|
||||||
|
expect(m.clampScroll(-50, 300), 0);
|
||||||
|
expect(m.clampScroll(5000, 300), 700);
|
||||||
|
expect(m.clampScroll(420, 300), 420);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dominantPageAt picks the page covering most of the viewport', () {
|
||||||
|
// viewport [0,100) fully on page 0.
|
||||||
|
expect(m.dominantPageAt(0, 100), 0);
|
||||||
|
// viewport [180,280): page1 [100,200) covers 20, page2 [200,300) covers
|
||||||
|
// 80 → page 2 dominates.
|
||||||
|
expect(m.dominantPageAt(180, 100), 2);
|
||||||
|
// viewport [150,250): page1 covers [150,200)=50, page2 [200,250)=50 →
|
||||||
|
// tie resolves to the first (page1) since overlap must strictly exceed.
|
||||||
|
expect(m.dominantPageAt(150, 100), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dominantPageAt clamps past the ends', () {
|
||||||
|
expect(m.dominantPageAt(-500, 100), 0);
|
||||||
|
expect(m.dominantPageAt(99999, 100), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dominantPageAt is 0 for an empty document', () {
|
||||||
|
final empty = PageStackMetrics(pageHeights: []);
|
||||||
|
expect(empty.dominantPageAt(0, 800), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('visibleRange with gaps', () {
|
group('visibleRange with gaps', () {
|
||||||
test('gap bands are not page bands (a scroll inside a gap shows neighbors)',
|
test('gap bands are not page bands (a scroll inside a gap shows neighbors)',
|
||||||
() {
|
() {
|
||||||
|
|||||||
Reference in New Issue
Block a user