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>
197 lines
6.5 KiB
Dart
197 lines
6.5 KiB
Dart
// lib/editor/layout/page_viewport.dart
|
|
//
|
|
// Continuous-single layout geometry: the PURE windowing math that decides which
|
|
// pages are mounted for a given scroll position (P0.5 step 10). Pages stack
|
|
// vertically; only the pages intersecting the viewport ± a cache band are
|
|
// mounted (windowed lazy hosting → 60fps on a 300-page doc, R1).
|
|
//
|
|
// This file is intentionally widget-free and pdfrx-free: the actual page
|
|
// mounting (a re-laid-out PdfPageView + the AnnotationLayer per page) and the
|
|
// zoom-settle DPI refresh are device-gated and live in the viewport WIDGET +
|
|
// pdf/page_tile.dart. The geometry is separated so it is unit-testable without
|
|
// a GPU or a real document.
|
|
|
|
import 'dart:math' as math;
|
|
|
|
/// An inclusive range of page indices to mount. [isEmpty] when nothing
|
|
/// intersects (e.g. an empty document or a scroll position past the end with no
|
|
/// cache band reaching back).
|
|
class PageWindow {
|
|
const PageWindow(this.first, this.last);
|
|
|
|
/// Sentinel empty window.
|
|
static const PageWindow empty = PageWindow(0, -1);
|
|
|
|
final int first;
|
|
final int last;
|
|
|
|
bool get isEmpty => last < first;
|
|
int get count => isEmpty ? 0 : (last - first + 1);
|
|
|
|
bool contains(int index) => !isEmpty && index >= first && index <= last;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is PageWindow && other.first == first && other.last == last;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(first, last);
|
|
|
|
@override
|
|
String toString() => isEmpty ? 'PageWindow.empty' : 'PageWindow($first..$last)';
|
|
}
|
|
|
|
/// Vertical stacking metrics for continuous-single layout.
|
|
///
|
|
/// Page `i` occupies the half-open band `[offsetOf(i), offsetOf(i) + heightOf(i))`
|
|
/// in content (scale-1) coordinates, with [gap] inserted between consecutive
|
|
/// pages. Cumulative tops are precomputed so [visibleRange] is O(log n) per
|
|
/// scroll frame.
|
|
class PageStackMetrics {
|
|
PageStackMetrics({required List<double> pageHeights, this.gap = 0.0})
|
|
: assert(gap >= 0),
|
|
_heights = List<double>.unmodifiable(pageHeights),
|
|
_tops = _cumulativeTops(pageHeights, gap);
|
|
|
|
final List<double> _heights;
|
|
|
|
/// Top edge of each page in content coordinates (length == pageCount).
|
|
final List<double> _tops;
|
|
|
|
/// Gap between consecutive pages in content units.
|
|
final double gap;
|
|
|
|
int get pageCount => _heights.length;
|
|
|
|
/// Total scrollable content height (0 when there are no pages).
|
|
double get totalExtent {
|
|
if (_heights.isEmpty) return 0;
|
|
return _tops.last + _heights.last;
|
|
}
|
|
|
|
double heightOf(int index) => _heights[index];
|
|
|
|
/// Top edge (content coordinate) of page [index].
|
|
double offsetOf(int index) => _tops[index];
|
|
|
|
static List<double> _cumulativeTops(List<double> heights, double gap) {
|
|
final tops = List<double>.filled(heights.length, 0);
|
|
var acc = 0.0;
|
|
for (var i = 0; i < heights.length; i++) {
|
|
tops[i] = acc;
|
|
acc += heights[i] + gap;
|
|
}
|
|
return List<double>.unmodifiable(tops);
|
|
}
|
|
|
|
/// The inclusive page range intersecting the viewport
|
|
/// `[scrollOffset, scrollOffset + viewportExtent)` grown by [cacheExtent] on
|
|
/// each side. Pages whose band overlaps the grown window (even partially) are
|
|
/// included; the result is clamped to `[0, pageCount-1]`.
|
|
///
|
|
/// Returns [PageWindow.empty] for an empty document or a window that does not
|
|
/// reach any page.
|
|
PageWindow visibleRange(
|
|
double scrollOffset,
|
|
double viewportExtent, {
|
|
double cacheExtent = 0.0,
|
|
}) {
|
|
if (_heights.isEmpty) return PageWindow.empty;
|
|
assert(viewportExtent >= 0);
|
|
assert(cacheExtent >= 0);
|
|
|
|
final double windowTop = scrollOffset - cacheExtent;
|
|
final double windowBottom = scrollOffset + viewportExtent + cacheExtent;
|
|
|
|
// No overlap with the content at all.
|
|
if (windowBottom <= 0 || windowTop >= totalExtent) {
|
|
return PageWindow.empty;
|
|
}
|
|
|
|
final int first = _firstIntersecting(windowTop);
|
|
final int last = _lastIntersecting(windowBottom);
|
|
if (last < first) return PageWindow.empty;
|
|
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
|
|
/// that the window's top edge does not sit fully below).
|
|
int _firstIntersecting(double y) {
|
|
// Find the first page whose bottom edge (top + height) > y.
|
|
var lo = 0;
|
|
var hi = _heights.length - 1;
|
|
var result = _heights.length - 1;
|
|
while (lo <= hi) {
|
|
final mid = (lo + hi) >> 1;
|
|
final bottom = _tops[mid] + _heights[mid];
|
|
if (bottom > y) {
|
|
result = mid;
|
|
hi = mid - 1;
|
|
} else {
|
|
lo = mid + 1;
|
|
}
|
|
}
|
|
return math.max(0, result);
|
|
}
|
|
|
|
/// Highest index whose top edge is strictly before [y].
|
|
int _lastIntersecting(double y) {
|
|
var lo = 0;
|
|
var hi = _heights.length - 1;
|
|
var result = 0;
|
|
while (lo <= hi) {
|
|
final mid = (lo + hi) >> 1;
|
|
if (_tops[mid] < y) {
|
|
result = mid;
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid - 1;
|
|
}
|
|
}
|
|
return math.min(_heights.length - 1, result);
|
|
}
|
|
}
|