Skip to content

Allow a bounded over-pan past the page edges - #296

Open
ben-milanko wants to merge 12 commits into
mainfrom
claude/session-t24ufa
Open

Allow a bounded over-pan past the page edges#296
ben-milanko wants to merge 12 commits into
mainfrom
claude/session-t24ufa

Conversation

@ben-milanko

@ben-milanko ben-milanko commented Jul 16, 2026

Copy link
Copy Markdown
Owner

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 (_SlugPagePicturePainter in a plain CustomPaint) 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.

  • New helpers next to the clamp methods: _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.
  • Every interactive horizontal clamp routes through these bounds: _clampedTransform (x only), _onPanFlingTick, _rubberBandClamp, _horizontalOverscroll, _springBackHorizontal. The touch rubber-band's free zone now extends to the margin (you can rest anywhere in edge ± 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.
  • Horizontal only, and why: sideways overflow lives entirely in the zoom-window transform (pages are laid out fit-width or narrower and centered — there is no horizontal scrollable), so widening the transform's x-clamp is the whole story. The vertical axis is driven by the scrollable's own extents, and the transform's y-translation only covers the current view; widening its y-clamp would let the zoom window rest on blank canvas mid-document, which exact_extent_test.dart explicitly guards against. So the vertical clamp stays tight.
  • Programmatic placement (_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 via Rect.intersect, _captureViewport re-clamps on restore, and the rubber-band already produced transient out-of-range x-translations that every consumer of storage[12] already tolerated.

Affected packages

  • pdf_cos
  • pdf_document
  • pdf_graphics
  • dart_pdf_editor
  • pdf_test_fixtures
  • Example app
  • Documentation / CI / repository metadata only

Change type

  • Bug fix
  • Feature
  • Rendering change
  • Editing behavior change
  • Performance change
  • Refactor / cleanup
  • Tests / fixtures only
  • Documentation only

(Viewer navigation / gesture behavior change.)

Validation

  • fvm flutter pub get
  • fvm dart analyze (clean on pdf_viewer.dart)
  • cd packages/pdf_cos && fvm dart test
  • cd packages/pdf_document && fvm dart test
  • cd packages/pdf_graphics && fvm dart test
  • cd packages/dart_pdf_editor && fvm flutter test (full package suite passing)
  • Ghent corpus test or baseline update
  • Real PDF corpus parse/render smoke test
  • Example app smoke test

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

  • No dart:ui or Flutter imports outside packages/dart_pdf_editor.
  • No dart:io imports in any lib/ directory.
  • Layering is preserved: pdf_cos <- pdf_document <- pdf_graphics <- dart_pdf_editor.
  • Parsers remain lenient on real-world input and writers remain strict on output.
  • Raw PDF stream bytes stay lazy/raw until decoding is required.
  • Test fixtures use builders/programmatic generation instead of hand-edited byte offsets.

Notes for reviewers

  • The margin is min(viewportWidth * 0.25, 160px) — tune here if a different "reasonable amount" is preferred.
  • Scoped to the horizontal axis. A first pass also widened the vertical clamp, but that let the zoom window rest on blank canvas mid-document and broke exact_extent_test.dart's "zoomed canvas gaps keep scrolling with the select tool armed" (which asserts storage[13] stays within [height * (1 - scale), 0]). Vertical document-end overscroll, if ever wanted, belongs to the ListView scroll physics rather than this transform, and is left as possible follow-up.
  • New test: pdf_viewer_test.dart → "trackpad pan rests a reasonable amount past the page edge" (zoom in, pan past the left edge, assert captureViewport().left rests < 0 but > -0.2). Existing rubber-band/within-bounds and exact_extent_test.dart bound assertions pass unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FpGn3Q14gzGj8MakXNubdg


Generated by Claude Code

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

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.05%. Comparing base (0d551ce) to head (4e84342).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
packages/dart_pdf_editor/lib/src/pdf_viewer.dart 97.22% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
app 78.17% <ø> (+0.27%) ⬆️
dart_pdf_editor 90.27% <97.36%> (-0.15%) ⬇️
pdf_cos 92.08% <ø> (+0.92%) ⬆️
pdf_document 86.41% <ø> (+0.14%) ⬆️
pdf_graphics 90.50% <ø> (-0.03%) ⬇️
pdf_ocr_vlm 87.73% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ackages/dart_pdf_editor/lib/src/page_geometry.dart 100.00% <100.00%> (ø)
...kages/dart_pdf_editor/lib/src/pdf_editor_view.dart 89.47% <100.00%> (+0.03%) ⬆️
packages/dart_pdf_editor/lib/src/pdf_viewer.dart 94.84% <97.22%> (+0.02%) ⬆️

... and 47 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Example demo preview

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

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

DartPDF app preview

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

claude and others added 11 commits July 16, 2026 06:35
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants