From eb9cea33e725b24ea806c54634fd53691cb53c2a Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 18 Jun 2026 03:17:42 +0900 Subject: [PATCH] =?UTF-8?q?Fix=20Crop=E2=86=92Blur=20viewport=20desync=20a?= =?UTF-8?q?fter=20a=20crop=20zoom=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the crop zoom changed and the user switched to Blur (tool/mask) mode, the crop output rendered small and pinned to the top-left until the tabs were toggled again. `makeToolCropDisplayViewport` derives the canvas placement from presentation-layer conversions, gated only on `isInteractiveZoomGestureActive == false`. The Crop→Blur switch applies the tool viewport as a one-shot, synchronously, in the same runloop turn that `updateToolScrollGeometry` just reconfigured the freshly-un-hidden tool scroll view (non-animated `setZoomScale`, etc.). Core Animation has not committed that geometry to the presentation layers yet, so the one-shot sampled the previous session's stale presentation transform — small + top-left placement — and with no display link to re-sample, the stale frame persisted until the next mode switch. Presentation-layer reads are only meaningful while the viewport display link is actively re-sampling an in-flight bounce. Gate them on `toolSurface.viewportRendering.isRunning` so the link-idle one-shot paths fall back to the model layers (which the synchronous reconfigure already made authoritative). All display-link-driven interaction/bounce paths are unchanged, so the bounce tracking from the previous fix still works. Co-Authored-By: Claude Opus 4.8 --- .../Shared/Components/Crop/CropView.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift index 10114634..ae58e921 100644 --- a/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift +++ b/Sources/BrightroomUI/Shared/Components/Crop/CropView.swift @@ -1603,7 +1603,20 @@ extension CropView { // while the scroll view's model values are already clamped to the minimum // zoom. Derive both the sampled output rect and the canvas placement from // layer conversion so the mask canvas follows that visible bounce. - let usesPresentationLayers = toolSurface.isInteractiveZoomGestureActive == false + // + // Presentation-layer reads are ONLY valid while the viewport display link is + // actively re-sampling that in-flight bounce. When the tool viewport is + // applied as a one-shot with the link idle — e.g. entering Blur from Crop, + // where `updateToolScrollGeometry` just reconfigured the freshly-un-hidden + // scroll view synchronously in this same runloop turn — the presentation + // layers still hold the previous session's geometry until the next Core + // Animation commit. Sampling them then renders the crop output small and + // pinned to the top-left, and with no display link to re-sample, that stale + // frame sticks until the next mode switch (the "switch back and forth fixes + // it" symptom). Fall back to the model layers, which the synchronous + // reconfigure already made authoritative, whenever the link is idle. + let usesPresentationLayers = toolSurface.viewportRendering.isRunning + && toolSurface.isInteractiveZoomGestureActive == false let canvasFrame = Self.currentLayerRect( bounds, from: self,