@@ -82,11 +82,20 @@ public bool ShouldShowPortalWindow
8282 partial void OnIsPortalPoppedOutChanged ( bool value )
8383 {
8484 OnPropertyChanged ( nameof ( ShouldShowPortalWindow ) ) ;
85- if ( ! value && _isPortalViewLocked )
85+ if ( value ) return ;
86+
87+ // Docking. A per-pop-out view lock is released and tracking catches up to the reading position;
88+ // EvaluatePortals re-pins (and renders) if the position moved to a different portal while locked.
89+ if ( _isPortalViewLocked )
8690 {
8791 ReleasePortalLock ( ) ;
8892 EvaluatePortals ( forceRender : true ) ;
8993 }
94+ // The docked preview is visible again. While popped-out + live the tracking crop was skipped
95+ // (#190) — re-materialise the current target now so the preview isn't blank/stale. A no-op (no
96+ // re-rasterise) unless a crop was actually elided; if the unlock above already re-pinned to a
97+ // different target it cleared the flag, so this won't double-render.
98+ RerenderDisplayedCrop ( ) ;
9099 }
91100
92101 // The pop-out window's content (PortalWindowImage/Label/Hint) and visibility (ShouldShowPortalWindow)
@@ -108,6 +117,11 @@ private void RaisePortalWindowProps()
108117 // True when the active portal's target page was not yet analysed at render time, so a forced pass
109118 // (target page just analysed) should retry instead of being debounced away.
110119 private bool _portalTargetPending ;
120+ // True when the currently displayed target's tracking crop was deliberately NOT rasterised because
121+ // it had no on-screen consumer (popped-out + live: PortalCropRedundant) — so ActivePortalImage does
122+ // not reflect _displayedPortalId. Docking re-materialises it (RerenderDisplayedCrop, #190). Cleared
123+ // whenever the crop is actually (re-)rendered or the displayed pin is cleared.
124+ private bool _trackingCropSkipped ;
111125 // Reading position (page, block, line) at the last evaluation — lets steady reading on one line
112126 // skip the whole match. Includes the line because line-precise sources can change the match as the
113127 // reading position advances line-by-line within a block.
@@ -133,10 +147,11 @@ private void RaisePortalWindowProps()
133147 // failure cannot re-trigger a full-page rasterisation on every pass. Cleared on tab switch.
134148 private readonly HashSet < string > _failedAutoPins = [ ] ;
135149 // Source/target blocks of the currently displayed auto pin, kept so the on-page markers can be
136- // drawn for it (auto pins are never saved Portals, so BuildPortalMarkers has nothing else to read).
137- // One field, set and cleared in lockstep with the "auto:" _displayedPortalId via SetAutoPin /
138- // ClearAutoPin, so the two can never drift out of sync.
139- private ( int SrcPage , int SrcBlock , int SrcLine , int TgtPage , int TgtBlock ) ? _autoPin ;
150+ // drawn for it (auto pins are never saved Portals, so BuildPortalMarkers has nothing else to read)
151+ // and so the float+caption union crop can be reproduced on a dock re-render (#190 — hence CaptionBlock).
152+ // One field, set and cleared in lockstep with the "auto:" _displayedPortalId, so the two can never
153+ // drift out of sync.
154+ private ( int SrcPage , int SrcBlock , int SrcLine , int TgtPage , int TgtBlock , int CaptionBlock ) ? _autoPin ;
140155 // _displayedPortalId values for auto pins, so they share the pin-until-different machinery with
141156 // saved portals without ever colliding with a saved portal's guid. The resolved target is part
142157 // of the identity, so a same-labelled but different float (numbering restarts across chapters)
@@ -309,6 +324,7 @@ private void ClearActivePortal()
309324 _displayedPortalId = null ;
310325 _autoPin = null ;
311326 _portalTargetPending = false ;
327+ _trackingCropSkipped = false ;
312328 SetPortalImage ( null ) ;
313329 ActivePortalLabel = null ;
314330 PortalHint = NoActivePortalHint ;
@@ -325,6 +341,7 @@ private void ResetDisplayedPortal()
325341 _displayedPortalId = null ;
326342 _autoPin = null ;
327343 _portalTargetPending = false ;
344+ _trackingCropSkipped = false ;
328345 ActivePortalLabel = null ;
329346 PortalHint = NoActivePortalHint ;
330347 InvalidatePortalMarkers ( ) ;
@@ -350,7 +367,7 @@ internal void EvaluatePortals(bool forceRender = false)
350367 if ( _controller . FocusedViewport ? . Owner is not { } doc || ActiveTab is not { } tab )
351368 {
352369 ClearPortalPeek ( ) ;
353- ClearActivePortal ( ) ;
370+ ClearActivePortal ( ) ; // also resets _trackingCropSkipped
354371 _portalImageOwner = null ;
355372 _lastEvalReadingBlock = null ;
356373 _autoRefPending = false ;
@@ -386,6 +403,7 @@ internal void EvaluatePortals(bool forceRender = false)
386403 _displayedPortalId = null ;
387404 _autoPin = null ;
388405 _portalTargetPending = false ;
406+ _trackingCropSkipped = false ;
389407 _pendingTargetForLink = null ;
390408 _pageSizeCache . Clear ( ) ;
391409 _referenceIndex . Clear ( ) ;
@@ -517,21 +535,30 @@ private void PinAutoReference(DocumentModel doc, ReferenceIndex.Reference refere
517535 string autoId = AutoPinId ( reference , target ) ;
518536 if ( ! doc . TryGetAnalysis ( target . Page , out var targetAnalysis ) ) return ;
519537 var blocks = targetAnalysis . Blocks ;
520- var region = Union ( blocks [ target . TargetBlock ] . BBox , blocks [ target . CaptionBlock ] . BBox ) ;
521- var bitmap = RenderRegionCrop ( doc , target . Page , region ) ;
522- if ( bitmap is null )
538+
539+ // Render the float+caption union crop into the tracking preview — unless that preview has no
540+ // on-screen consumer right now (#190: popped-out + live shows the confined viewport instead and
541+ // re-renders on dock). The skip still pins + aims the live viewport; only the rasterise is elided.
542+ Bitmap ? bitmap = null ;
543+ if ( ! PortalCropRedundant )
523544 {
524- // Remember the failure: a deterministic render failure must not re-trigger a full-page
525- // rasterisation on every line advance / forced pass. Cleared on tab switch.
526- _failedAutoPins . Add ( autoId ) ;
527- return ; // keep whatever was shown
545+ var region = Union ( blocks [ target . TargetBlock ] . BBox , blocks [ target . CaptionBlock ] . BBox ) ;
546+ bitmap = RenderRegionCrop ( doc , target . Page , region ) ;
547+ if ( bitmap is null )
548+ {
549+ // Remember the failure: a deterministic render failure must not re-trigger a full-page
550+ // rasterisation on every line advance / forced pass. Cleared on tab switch.
551+ _failedAutoPins . Add ( autoId ) ;
552+ return ; // keep whatever was shown
553+ }
528554 }
529555
530556 _displayedPortalId = autoId ;
531- _autoPin = ( srcPage , srcBlock , srcLine , target . Page , target . TargetBlock ) ;
557+ _autoPin = ( srcPage , srcBlock , srcLine , target . Page , target . TargetBlock , target . CaptionBlock ) ;
532558 _portalTargetPending = false ;
559+ _trackingCropSkipped = bitmap is null ; // skipped while redundant → re-render on dock
533560 ActivePortalLabel = $ "{ reference } (p.{ target . Page + 1 } ) · auto";
534- SetPortalImage ( bitmap ) ;
561+ SetPortalImage ( bitmap ) ; // null while redundant: releases the prior crop (nothing shows it now)
535562 PortalHint = null ;
536563 InvalidatePortalMarkers ( ) ; // a previously accented saved portal loses the accent
537564 RaisePortalView ( ) ; // auto pin changed → (re-)aim the live portal viewport at the float
@@ -554,27 +581,84 @@ private void RenderPortalTarget(DocumentModel doc, int page, int block)
554581 if ( ! resolvable )
555582 {
556583 _portalTargetPending = true ;
584+ _trackingCropSkipped = false ; // nothing to skip — waiting on analysis
557585 SetPortalImage ( null ) ;
558586 PortalHint = "Resolving target…" ;
559587 RaisePortalView ( ) ; // unresolved → live view shows hint until the page is analysed
560588 return ;
561589 }
562590
563591 _portalTargetPending = false ;
592+
593+ // #190: while popped-out AND live, the docked preview is hidden and the pop-out shows the live
594+ // confined viewport — the 300-DPI crop has no on-screen consumer, so skip the rasterise. Docking
595+ // re-materialises it (RerenderDisplayedCrop). The live viewport renders the block itself, so it's
596+ // still aimed below via RaisePortalView.
597+ if ( PortalCropRedundant )
598+ {
599+ _trackingCropSkipped = true ;
600+ SetPortalImage ( null ) ; // release the prior crop (nothing on-screen consumes it now)
601+ PortalHint = null ;
602+ RaisePortalView ( ) ;
603+ return ;
604+ }
605+
564606 var bitmap = RenderBlockCrop ( doc , page , block ) ;
565607 if ( bitmap is null )
566608 {
609+ _trackingCropSkipped = false ;
567610 SetPortalImage ( null ) ;
568611 PortalHint = "Could not render portal target." ;
569612 // The live viewport renders the block directly (it doesn't need the crop), so still aim it.
570613 RaisePortalView ( ) ;
571614 return ;
572615 }
616+ _trackingCropSkipped = false ;
573617 SetPortalImage ( bitmap ) ;
574618 PortalHint = null ;
575619 RaisePortalView ( ) ; // saved target resolved → (re-)aim the live portal viewport at it
576620 }
577621
622+ /// <summary>Materialise the currently displayed target's tracking crop into <see cref="ActivePortalImage"/>
623+ /// when it was elided (#190). The render paths skip the rasterise while popped-out + live (the docked
624+ /// preview is hidden); docking re-reveals that preview, so the crop must be produced now or it would
625+ /// stay blank/stale. A no-op unless a crop was actually skipped (so a normal dock never re-rasterises).
626+ /// UI thread only (PDFium).</summary>
627+ private void RerenderDisplayedCrop ( )
628+ {
629+ if ( ! _trackingCropSkipped ) return ;
630+ if ( _controller . FocusedViewport ? . Owner is not { } doc || ActiveTab is not { } tab
631+ || _displayedPortalId is null )
632+ {
633+ _trackingCropSkipped = false ;
634+ return ;
635+ }
636+
637+ if ( _autoPin is { } a )
638+ {
639+ // Reproduce the auto pin's float+caption union crop (the stored CaptionBlock makes this
640+ // possible without re-parsing the reference). A render failure here just leaves the docked
641+ // preview blank; the flag clears either way so it can't retry-loop on every dock.
642+ _trackingCropSkipped = false ;
643+ if ( doc . TryGetAnalysis ( a . TgtPage , out var analysis )
644+ && a . TgtBlock >= 0 && a . TgtBlock < analysis . Blocks . Count
645+ && a . CaptionBlock >= 0 && a . CaptionBlock < analysis . Blocks . Count )
646+ {
647+ var region = Union ( analysis . Blocks [ a . TgtBlock ] . BBox , analysis . Blocks [ a . CaptionBlock ] . BBox ) ;
648+ SetPortalImage ( RenderRegionCrop ( doc , a . TgtPage , region ) ) ;
649+ }
650+ }
651+ else if ( tab . Portals . Portals . FirstOrDefault ( p => p . Id == _displayedPortalId ) is { } portal )
652+ {
653+ // Now docked (not redundant), RenderPortalTarget rasterises and clears _trackingCropSkipped.
654+ RenderPortalTarget ( doc , portal . Target . Page , ResolveAnchorBlock ( doc , portal . Target ) ) ;
655+ }
656+ else
657+ {
658+ _trackingCropSkipped = false ; // displayed id no longer matches a saved/auto pin
659+ }
660+ }
661+
578662 /// <summary>Rasterise a detected block region and decode it to an Avalonia <see cref="Bitmap"/>,
579663 /// or null if the page/block is unavailable or rendering fails. UI thread only (PDFium).</summary>
580664 private Bitmap ? RenderBlockCrop ( DocumentModel doc , int page , int block )
0 commit comments