Allow a bounded over-pan past the page edges - #296
Open
ben-milanko wants to merge 12 commits into
Open
Conversation
The zoom-window pan clamp pinned the content edges to the viewport edges, so material drawn outside the page box (bleed, crop/registration marks, art overflowing the MediaBox) - which the vector display path paints unclipped - couldn't be brought into view. Widen the allowed translation by a bounded over-pan margin (min(viewport * 0.25, 160px)) on each axis via new _panMarginX/Y and _panBoundsX/Y helpers, and route every interactive clamp through them (_clampedTransform(VerticalOnly), fling, rubber-band, overscroll, and spring-back). The touch rubber-band's free zone extends to the margin and springs back to the margin edge rather than the page edge; non-touch pans rest in the margin. Programmatic placement (restore viewport, frame rect) stays tight so it lands in-bounds. Downstream is unaffected: the thumbnail indicator intersect-clamps its fractions, capture re-clamps on restore, and the rubber-band already produced transient out-of-range translations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #296 +/- ##
==========================================
+ Coverage 88.98% 89.05% +0.06%
==========================================
Files 238 245 +7
Lines 49298 50125 +827
==========================================
+ Hits 43870 44641 +771
- Misses 5428 5484 +56
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Contributor
Example demo previewVisit the Example demo preview preview URL for this PR (updated for commit 394e3b0): https://dart-pdf-demo--pr296-claude-session-t24uf-u4d5f6o2.web.app (expires Sat, 25 Jul 2026 03:25:32 GMT) |
Contributor
DartPDF app previewVisit the DartPDF app preview preview URL for this PR (updated for commit 394e3b0): https://dartpdf-app--pr296-claude-session-t24uf-qdf97d06.web.app (expires Sat, 25 Jul 2026 03:25:37 GMT) |
The vertical over-pan margin let the zoom window rest on blank canvas mid-document, which exact_extent_test.dart's "zoomed canvas gaps keep scrolling" guards against (storage[13] must stay within [height*(1-scale), 0]). Vertical scrolling is the ListView's job; the transform's y-translation only covers the current view. Keep the margin horizontal only, where sideways off-page content (bleed, crop marks, art overflowing the MediaBox, narrow pages islanded in a wide canvas) lives entirely in the transform. Revert the vertical clamps in _clampedTransform/_clampedTransformVerticalOnly to the tight cover bound; drop the unused _panMarginY/_panBoundsY helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
The over-pan clamp only widened the zoom-window translation when zoomed in past 100%; at the default fit zoom the transform was forced to identity, so there was no way to pan sideways past the page edge to reach off-page material (bleed, crop marks, or a pasteboard margin). Replace the `scale <= 1.01 => identity` early-outs in the pan clamp, fling, overscroll, and bounce-tick helpers with the general bounds at the actual scale - `_panBoundsX(1)` is `(-margin, margin)`, so at fit zoom a bounded *horizontal* pan onto the pasteboard is allowed while the scale stays unit. Vertical stays pinned at fit zoom: the list owns vertical scrolling, so the `exact_extent_test` vertical-bound guard is unaffected. This is phase 1 of off-page annotation authoring: the pan now reaches the pasteboard, but that margin is still blank. Phase 2 adds the drawable surface and off-page coordinate mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
Phase 1 only wired the fit-zoom pasteboard pan into the trackpad pan-zoom *gesture* path. On the web there are no PointerPanZoomEvents - a two-finger sideways trackpad swipe (and shift+mouse-wheel) arrives as a PointerScrollEvent handled by `_onPointerSignal`, which applied its horizontal delta only `if (zoomed)`. So at the default fit zoom the pasteboard was unreachable in a browser. Apply the horizontal wheel delta at every zoom (the clamp bounds it to the pasteboard margin at fit zoom) and update the transform whenever there is a horizontal component, not only when zoomed. Vertical stays list-driven and pinned at fit zoom. Tests: fit-zoom horizontal trackpad wheel and shift+mouse-wheel each rest `captureViewport().left` a bounded distance off the edge without scrolling the list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
Give PdfPageGeometry an optional `origin` (default Offset.zero, so existing callers are unchanged): the page's top-left within the coordinate space the view offsets live in. When the page is inset inside a larger drawing surface, the surrounding band is the off-page pasteboard, and view positions there map - unclamped - to PDF coordinates outside the crop box. This is the enabling piece for drawing annotations in a page margin: the existing toPagePoint already never clamps, so a gesture in the margin yields a real off-page coordinate. Test: an inset origin puts the page's top-left at the origin and maps a gesture in the left band to a negative (off-page) x, and still round-trips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
`PdfViewer.pasteboardMargin` (and the `PdfEditorView` passthrough, default 0 = off) reserves a blank band on each side of every page: the widest page fit-widths to `viewport - 2*margin`, so a strip of canvas stays visible beside it. That band is a real, laid-out part of the tile, so a pen/mouse lands in it - and because `PdfPageGeometry.toPagePoint` and the ink path never clamp, a stroke there authors an annotation with a PDF coordinate outside the crop box that saves with the page. This is what "draw an annotation outside the page and pan to it" actually needs: an on-screen surface to draw on, reachable with any input. - `_PdfViewerPage` insets its page-space layers (raster, appearance, highlight, field wash, ghost) in a horizontal `Padding`, while the editing overlay spans the full tile and builds its geometry with `origin: (margin, 0)` so band gestures map off-page. - Fit math routes through `_contentWidth = viewWidth - 2*_pasteboardX`; at `pasteboardMargin == 0` every value is unchanged, so the default view and the full suite (1455 tests) are unaffected. - The example app enables a 96px band so the demo shows it. Test: a stylus stroke in the left band commits an Ink annotation whose first point is left of the crop box, and the off-page /Rect round-trips through save/reload. Follow-ups (see the dev-log): notes/shapes/free-text still clamp to the page; vertical band; flatten MediaBox expansion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
# Conflicts: # packages/dart_pdf_editor/lib/src/pdf_viewer.dart
The gap between pages was a constant pixel value (pageSpacing), added unscaled to the layout offsets. Below the fit zoom the pages lay out smaller (via _layoutZoom) while the gap stayed fixed, so the gap grew relative to the pages as you zoomed out - it read as the spacing "changing" (and looking wrong) across the zoom range. Measured: the gap/page-height ratio nearly doubled between two zoomed-out levels. Introduce `_spacing = pageSpacing * _layoutZoom` and route every layout site (item extents, scroll offsets, tile padding, overflow thresholds) through it, so the gap tracks the page size. At fit (_layoutZoom == 1) it is exactly pageSpacing, so the default view and existing offset-math tests are unchanged; above fit the transform already scales the whole list, gap included. Below fit the gap now stays in proportion. Test: pdf_page_spacing_test.dart asserts the gap/page-height ratio is stable across two zoomed-out levels (it used to jump ~0.029 -> 0.046). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
CI runs dart analyze --fatal-infos; material.dart already provides RenderBox, so the extra import tripped the unnecessary_import lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
The drawable pasteboard reserved a constant pixel inset on each side of the page, but the page tile scales with the layout zoom (_crossFactor). Below fit the constant inset left the raster short of its (shrunken) tile, and because the raster keeps its aspect it fell short vertically too - the leftover showed as a large, growing gap between pages when zooming out (~aspect * 2*margin * (1 - layoutZoom)). Scale the inset passed to the page by _layoutZoom so it tracks the tile: the raster then fills its tile at every zoom and the inter-page gap stays proportional. At fit (layoutZoom == 1) nothing changes. Test: pdf_page_spacing_test now runs the gap-ratio-across-zoom check with a pasteboard margin as well as without; the pasteboard case used to blow up when zooming out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
The pasteboard band was left/right only, so there was no space above the first page (panning up at the top did nothing) or below the last. Frame every page with a uniform band on all four sides. - `_pbMain = _pasteboardX * _layoutZoom` is the main-axis (top+bottom) band. Each page's scroll slot grows by 2*_pbMain: `_scrollExtentOf` and the itemExtentBuilder add it, and the inline slot-advance loops (current-page detection, `_captureViewport`, render-warm) do too. - `_mainOffsetOf(index)` is redefined as the raster top (`_slotStart + leadingSpacing + _pbMain`) off the shared `_scrollExtentOf`. Every search/jump/viewport site already adds a within-page fraction of `_pageMain` to it, so folding the top band in here keeps them all correct; `_pagePointAt`/`_pageContainsListPoint` route through it too. - `_PdfViewerPage`: `EdgeInsets.all(pasteboard)` and geometry `origin: Offset(pasteboard, pasteboard)` with viewSize reduced on both axes, so a gesture above the page maps to a PDF y past the crop box top. All of this is 0 (a strict no-op) when the pasteboard is off, so the default view and the existing suite are unchanged. Tests: a stroke above the page authors an off-page `y > cropBox.top`; the gap-ratio-across-zoom check runs with the pasteboard on and off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
This branch predates the affected welcome-screen test; its PR merge build inherits the fix from main. References 9d746d6664d0b735d3ff64640eb701e703259bcc.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The zoom-window pan clamp pinned the content edges to the viewport edges, so material drawn outside the page box — bleed, crop/registration marks, art overflowing the MediaBox, a narrow page islanded in a wide canvas — couldn't be brought into view. The vector display path (
_SlugPagePicturePainterin a plainCustomPaint) applies no clip at the page boundary, so that material is actually painted; it just wasn't reachable by panning.This widens the allowed zoom-window translation by a bounded over-pan margin on the horizontal axis, so a reasonable amount of off-page content can be pulled in from the side edge instead of being pinned there.
_panMarginX=min(viewportWidth * 0.25, 160.0)(a modest fraction of the viewport, capped so a large viewport doesn't pan off into a wide empty margin), and_panBoundsX(scale)returning the tight cover range widened by the margin on each side._clampedTransform(x only),_onPanFlingTick,_rubberBandClamp,_horizontalOverscroll,_springBackHorizontal. The touch rubber-band's free zone now extends to the margin (you can rest anywhere inedge ± margin); past the margin it dampens and springs back to the margin edge. Non-touch pans (mouse/trackpad/scrollbar/fling) hard-clamp to the widened range and rest in the margin.exact_extent_test.dartexplicitly guards against. So the vertical clamp stays tight._placeViewport,_showRect) is left tight on purpose, so restoring a saved viewport or framing a rect still lands in-bounds.Downstream is unaffected:
_visibleFractionOf(thumbnail indicator) clamps viaRect.intersect,_captureViewportre-clamps on restore, and the rubber-band already produced transient out-of-range x-translations that every consumer ofstorage[12]already tolerated.Affected packages
pdf_cospdf_documentpdf_graphicsdart_pdf_editorpdf_test_fixturesChange type
(Viewer navigation / gesture behavior change.)
Validation
fvm flutter pub getfvm dart analyze(clean onpdf_viewer.dart)cd packages/pdf_cos && fvm dart testcd packages/pdf_document && fvm dart testcd packages/pdf_graphics && fvm dart testcd packages/dart_pdf_editor && fvm flutter test(full package suite passing)Ran the touched package's full Flutter test suite; other packages are untouched by this change.
PDF fixtures and screenshots
New test uses the existing programmatic fixtures — no PDFs attached.
Compatibility checklist
dart:uior Flutter imports outsidepackages/dart_pdf_editor.dart:ioimports in anylib/directory.pdf_cos<-pdf_document<-pdf_graphics<-dart_pdf_editor.Notes for reviewers
min(viewportWidth * 0.25, 160px)— tune here if a different "reasonable amount" is preferred.exact_extent_test.dart's "zoomed canvas gaps keep scrolling with the select tool armed" (which assertsstorage[13]stays within[height * (1 - scale), 0]). Vertical document-end overscroll, if ever wanted, belongs to theListViewscroll physics rather than this transform, and is left as possible follow-up.pdf_viewer_test.dart→ "trackpad pan rests a reasonable amount past the page edge" (zoom in, pan past the left edge, assertcaptureViewport().leftrests< 0but> -0.2). Existing rubber-band/within-bounds andexact_extent_test.dartbound assertions pass unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg
Generated by Claude Code