From dc6feb50687021d945b371239af45672d379df35 Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 24 Jul 2026 17:52:23 +0200 Subject: [PATCH 1/2] feat(layer-interaction): enhance alignment guide behavior during drag interactions --- .../services/layer_interaction_manager.dart | 90 ++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/lib/features/main_editor/services/layer_interaction_manager.dart b/lib/features/main_editor/services/layer_interaction_manager.dart index c25553cc..b132acd4 100644 --- a/lib/features/main_editor/services/layer_interaction_manager.dart +++ b/lib/features/main_editor/services/layer_interaction_manager.dart @@ -442,6 +442,18 @@ class LayerInteractionManager { final _horizontalSnapHelper = _LayerAlignGuideHelper(); final _verticalSnapHelper = _LayerAlignGuideHelper(); + /// Whether [_updateAlignmentGuides] has set up its per-drag state yet. + bool _alignmentGuidesInitialized = false; + + /// X-axis snap targets the active layer already coincided with when the drag + /// started. They stay suppressed until the layer's nearest edge moves clear + /// (farther than the release threshold), so a layer lifted off a pile of + /// overlapping layers isn't trapped by their coincident alignment guides. + final Set _heldAlignXTargets = {}; + + /// Y-axis counterpart of [_heldAlignXTargets]. + final Set _heldAlignYTargets = {}; + /// Optional override for helper line configuration at runtime. /// /// When set, this takes precedence over [configs.helperLines], allowing @@ -980,6 +992,12 @@ class LayerInteractionManager { snapStartPosX = details.focalPoint.dx; snapStartPosY = details.focalPoint.dy; + _alignmentGuidesInitialized = false; + _heldAlignXTargets.clear(); + _heldAlignYTargets.clear(); + _horizontalSnapHelper.reset(); + _verticalSnapHelper.reset(); + for (Layer layer in selectedLayers) { _baseScaleFactor[layer.id] = layer.scale; _baseAngleFactor[layer.id] = layer.rotation; @@ -1034,6 +1052,9 @@ class LayerInteractionManager { hoverRemoveBtn = false; _activeClosestLocalOffsetX = null; _activeClosestLocalOffsetY = null; + _alignmentGuidesInitialized = false; + _heldAlignXTargets.clear(); + _heldAlignYTargets.clear(); } /// Rotate a layer. @@ -1227,6 +1248,37 @@ class LayerInteractionManager { final activeXAnchors = _horizontalSnapAnchors(activeLayer); final activeYAnchors = _verticalSnapAnchors(activeLayer); + if (!_alignmentGuidesInitialized) { + _alignmentGuidesInitialized = true; + // Remember the targets the active layer already coincides with when the + // drag begins, so they don't immediately trap it. Without this a layer + // that shares its position with others (e.g. a stack of overlapping + // layers) is held in place by their coincident guides and can't be + // dragged away until the pointer travels past every anchor. + _heldAlignXTargets + ..clear() + ..addAll( + xTargets + .where( + (t) => activeXAnchors.any( + (a) => (a.position - t.position).abs() <= snapThreshold, + ), + ) + .map((t) => t.position), + ); + _heldAlignYTargets + ..clear() + ..addAll( + yTargets + .where( + (t) => activeYAnchors.any( + (a) => (a.position - t.position).abs() <= snapThreshold, + ), + ) + .map((t) => t.position), + ); + } + _SnapGuideTarget? matchedX; _LayerSnapAnchor? matchedXAnchor; for (final target in xTargets) { @@ -1239,7 +1291,21 @@ class LayerInteractionManager { : b, ); - if ((anchor.position - target.position).abs() <= snapThreshold && + final double distanceX = (anchor.position - target.position).abs(); + + // A target the layer already sat on when the drag began stays suppressed + // until the layer moves clear of it, so overlapping layers can be pulled + // apart instead of being trapped by their coincident guides. Once clear, + // the target re-arms and snapping works normally on re-approach. + if (_heldAlignXTargets.contains(target.position)) { + if (distanceX > releaseThreshold) { + _heldAlignXTargets.remove(target.position); + } else { + continue; + } + } + + if (distanceX <= snapThreshold && _verticalSnapHelper.maybeSnap( focal: detail.focalPoint.dx, focalDelta: detail.focalPointDelta.dx, @@ -1268,7 +1334,19 @@ class LayerInteractionManager { : b, ); - if ((anchor.position - target.position).abs() <= snapThreshold && + final double distanceY = (anchor.position - target.position).abs(); + + // See the x-axis loop above: suppress targets the layer already sat on so + // stacked/overlapping layers can be separated. + if (_heldAlignYTargets.contains(target.position)) { + if (distanceY > releaseThreshold) { + _heldAlignYTargets.remove(target.position); + } else { + continue; + } + } + + if (distanceY <= snapThreshold && _horizontalSnapHelper.maybeSnap( focal: detail.focalPoint.dy, focalDelta: detail.focalPointDelta.dy, @@ -1353,6 +1431,14 @@ class _LayerAlignGuideHelper { Offset _lastSnapOffset = Offset.infinite; double? _lastSnapFocal; + /// Clears the snapping hysteresis so a new drag gesture starts fresh and + /// doesn't inherit stale state from a previous interaction. + void reset() { + _lastSnapPosition = LayerLastPosition.center; + _lastSnapOffset = Offset.infinite; + _lastSnapFocal = null; + } + /// Returns true if snapping should occur, otherwise false bool maybeSnap({ required double focal, From 87fe3ab71d7e30ae127fcfa7187c9ff8bdd38d79 Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 24 Jul 2026 17:53:03 +0200 Subject: [PATCH 2/2] chore(changelog): update version to 13.2.3 and document layer dragging fix --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbad10f..12f3b35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 13.2.3 +- **FIX**(main-editor): A layer sharing its position with others (a stack of overlapping layers) can now be dragged away instead of being trapped by their coincident alignment guides. Snapping re-arms once the layer moves clear. + ## 13.2.2 - **FIX**(main-editor): Ignore the spurious scale-end Flutter fires when a finger is added or removed mid-gesture (e.g. a third finger during a two-finger layer rotation). It previously ran the full interaction teardown, corrupting the history stack and crashing on iOS with a `_dependents.isEmpty` assertion (#850). diff --git a/pubspec.yaml b/pubspec.yaml index 61122fbd..628c3355 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_image_editor description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features." -version: 13.2.2 +version: 13.2.3 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/