From d3fc92ba21934dfe1e2328298e123b8bc24e6e36 Mon Sep 17 00:00:00 2001 From: Saif Addin Date: Fri, 23 Oct 2026 18:42:16 +0200 Subject: [PATCH 1/3] fix(layers): keep text position stable on first selection --- lib/shared/widgets/layer/layer_widget.dart | 10 +- .../text_layer_selection_position_test.dart | 178 ++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 test/features/main_editor/text_layer_selection_position_test.dart diff --git a/lib/shared/widgets/layer/layer_widget.dart b/lib/shared/widgets/layer/layer_widget.dart index 6b11dff4..f6f84d8c 100644 --- a/lib/shared/widgets/layer/layer_widget.dart +++ b/lib/shared/widgets/layer/layer_widget.dart @@ -439,6 +439,10 @@ class _LayerWidgetState extends State Widget _buildInteractionHandlers() { var interaction = _layer.interaction; + // Hit detection can rebuild the inner listener without rebuilding the + // layer's position. Keep its selection padding in sync with the outer + // positioning from this build until the whole layer rebuilds. + final isSelected = _isSelected; final inline = _layersService?.state.inlineTextEdit; final editingThis = inline != null && inline.layerId == _layer.id ? inline @@ -447,7 +451,7 @@ class _LayerWidgetState extends State layer: _layer, configs: configs, callbacks: callbacks, - selected: _isSelected, + selected: isSelected, suppressTextBoxResize: editingThis != null && !editingThis.boxEnabled, onEditLayer: () => _layersService?.handleEditTap(_layer), forceIgnoreGestures: @@ -492,7 +496,7 @@ class _LayerWidgetState extends State onPointerDown: _onPointerDown, onPointerUp: _onPointerUp, child: Padding( - padding: !_isSelected + padding: !isSelected ? EdgeInsets.zero : layerInteraction.style.overlayPadding, child: FittedBox( @@ -501,7 +505,7 @@ class _LayerWidgetState extends State layerType: _layerType, layer: _layer, inlineEdit: editingThis, - isSelected: _isSelected, + isSelected: isSelected, skipPaint: widget.isRasterCached, playTimeNotifier: widget.playTimeNotifier, mainBodySize: diff --git a/test/features/main_editor/text_layer_selection_position_test.dart b/test/features/main_editor/text_layer_selection_position_test.dart new file mode 100644 index 00000000..4d2ce0a3 --- /dev/null +++ b/test/features/main_editor/text_layer_selection_position_test.dart @@ -0,0 +1,178 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +import '../../mock/mock_image.dart'; + +/// Selection padding and its position compensation must change together. +void main() { + const configs = ProImageEditorConfigs( + progressIndicatorConfigs: ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: ImageGenerationConfigs( + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + ), + layerInteraction: LayerInteractionConfigs( + selectable: LayerInteractionSelectable.enabled, + enableTextBoxResize: true, + ), + ); + + // The companion app anchors text by its top-left corner. + final topLeftConfigs = configs.copyWith( + textEditor: const TextEditorConfigs(layerFractionalOffset: Offset.zero), + ); + + Future pumpEditor( + WidgetTester tester, { + ProImageEditorConfigs editorConfigs = configs, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: editorConfigs, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: (Uint8List bytes) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; + } + + for (final topLeft in [false, true]) { + for (final boxed in [false, true]) { + for (final transformed in [false, true]) { + for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + testWidgets( + 'text selection stays put on every frame ' + '(topLeft: $topLeft, boxed: $boxed, ' + 'transformed: $transformed, pointer: ${kind.name})', + (tester) async { + final state = await pumpEditor( + tester, + editorConfigs: topLeft ? topLeftConfigs : configs, + ); + final layer = TextLayer( + text: 'Name', + rotation: transformed ? math.pi / 6 : 0, + flipX: transformed, + flipY: false, + scale: transformed ? 1.4 : 1, + box: boxed ? const TextLayerBox(size: Size(160, 60)) : null, + ); + state + ..addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ) + ..unselectAllLayers(); + await tester.pumpAndSettle(); + final text = find.byType(RoundedBackgroundText); + Rect screenRect() { + final render = tester.renderObject(text); + return MatrixUtils.transformRect( + render.getTransformTo(null), Offset.zero & render.size); + } + final before = screenRect(); + final originalOffset = layer.offset; + void expectPosition(String reason) { + final after = screenRect(); + expect((after.topLeft - before.topLeft).distance, + lessThan(0.001), reason: reason); + expect((after.bottomRight - before.bottomRight).distance, + lessThan(0.001), reason: reason); + } + + // The first hit changes the hit notifier. Later hits must also + // keep the content in place when selecting and deselecting it. + for (var attempt = 0; attempt < 3; attempt++) { + final gesture = await tester.startGesture( + before.center, + kind: kind, + ); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(); + expectPosition('attempt $attempt, pointer down frame $frame'); + } + await gesture.up(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(); + expectPosition('attempt $attempt, pointer up frame $frame'); + } + await tester.pumpAndSettle(); + expectPosition('settled'); + expect(state.activeLayers.single.offset, originalOffset); + } + }, + variant: TargetPlatformVariant.only( + kind == PointerDeviceKind.mouse + ? TargetPlatform.linux : TargetPlatform.android), + ); + } + } + } + } + + testWidgets('multi-selection keeps text, emoji and widget content in place', + (tester) async { + final state = await pumpEditor(tester, editorConfigs: topLeftConfigs); + final layers = [ + TextLayer(text: 'Name', offset: const Offset(-140, -60)), + EmojiLayer(emoji: 'X', offset: const Offset(0, 40)), + WidgetLayer( + widget: const SizedBox(width: 60, height: 40), + offset: const Offset(140, -60), + rotation: math.pi / 6, + flipY: true, + ), + ]; + for (final layer in layers) { + state.addLayer(layer, + autoCorrectZoomOffset: false, autoCorrectZoomScale: false); + } + state.unselectAllLayers(); + await tester.pumpAndSettle(); + + Rect screenRect(Layer layer) { + final render = tester.renderObject( + find.byKey(state.activeLayers + .firstWhere((item) => item.id == layer.id).repaintBoundaryKey)); + return MatrixUtils.transformRect( + render.getTransformTo(null), Offset.zero & render.size); + } + final before = [for (final layer in layers) screenRect(layer)]; + for (final select in [true, false, true]) { + if (select) { + state.selectAllLayers(); + } else { + state.unselectAllLayers(); + } + for (var frame = 0; frame < 4; frame++) { + await tester.pump(); + for (var i = 0; i < layers.length; i++) { + final after = screenRect(layers[i]); + expect((after.topLeft - before[i].topLeft).distance, + lessThan(0.001), reason: 'layer $i, frame $frame'); + expect((after.bottomRight - before[i].bottomRight).distance, + lessThan(0.001), reason: 'layer $i, frame $frame'); + } + } + expect(state.selectedLayers.length, select ? layers.length : 0); + } + }); +} From 760915e3b8a3506be8cde0fe0d1d40390f390383 Mon Sep 17 00:00:00 2001 From: Saif Addin Date: Fri, 23 Oct 2026 18:59:17 +0200 Subject: [PATCH 2/3] fix(layers): keep text position stable format --- .../text_layer_selection_position_test.dart | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/test/features/main_editor/text_layer_selection_position_test.dart b/test/features/main_editor/text_layer_selection_position_test.dart index 4d2ce0a3..fc1a0b58 100644 --- a/test/features/main_editor/text_layer_selection_position_test.dart +++ b/test/features/main_editor/text_layer_selection_position_test.dart @@ -86,16 +86,25 @@ void main() { Rect screenRect() { final render = tester.renderObject(text); return MatrixUtils.transformRect( - render.getTransformTo(null), Offset.zero & render.size); + render.getTransformTo(null), + Offset.zero & render.size, + ); } + final before = screenRect(); final originalOffset = layer.offset; void expectPosition(String reason) { final after = screenRect(); - expect((after.topLeft - before.topLeft).distance, - lessThan(0.001), reason: reason); - expect((after.bottomRight - before.bottomRight).distance, - lessThan(0.001), reason: reason); + expect( + (after.topLeft - before.topLeft).distance, + lessThan(0.001), + reason: reason, + ); + expect( + (after.bottomRight - before.bottomRight).distance, + lessThan(0.001), + reason: reason, + ); } // The first hit changes the hit notifier. Later hits must also @@ -121,15 +130,18 @@ void main() { }, variant: TargetPlatformVariant.only( kind == PointerDeviceKind.mouse - ? TargetPlatform.linux : TargetPlatform.android), + ? TargetPlatform.linux + : TargetPlatform.android, + ), ); } } } } - testWidgets('multi-selection keeps text, emoji and widget content in place', - (tester) async { + testWidgets('multi-selection keeps text, emoji and widget content in place', ( + tester, + ) async { final state = await pumpEditor(tester, editorConfigs: topLeftConfigs); final layers = [ TextLayer(text: 'Name', offset: const Offset(-140, -60)), @@ -142,19 +154,29 @@ void main() { ), ]; for (final layer in layers) { - state.addLayer(layer, - autoCorrectZoomOffset: false, autoCorrectZoomScale: false); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); } state.unselectAllLayers(); await tester.pumpAndSettle(); Rect screenRect(Layer layer) { final render = tester.renderObject( - find.byKey(state.activeLayers - .firstWhere((item) => item.id == layer.id).repaintBoundaryKey)); + find.byKey( + state.activeLayers + .firstWhere((item) => item.id == layer.id) + .repaintBoundaryKey, + ), + ); return MatrixUtils.transformRect( - render.getTransformTo(null), Offset.zero & render.size); + render.getTransformTo(null), + Offset.zero & render.size, + ); } + final before = [for (final layer in layers) screenRect(layer)]; for (final select in [true, false, true]) { if (select) { @@ -166,10 +188,16 @@ void main() { await tester.pump(); for (var i = 0; i < layers.length; i++) { final after = screenRect(layers[i]); - expect((after.topLeft - before[i].topLeft).distance, - lessThan(0.001), reason: 'layer $i, frame $frame'); - expect((after.bottomRight - before[i].bottomRight).distance, - lessThan(0.001), reason: 'layer $i, frame $frame'); + expect( + (after.topLeft - before[i].topLeft).distance, + lessThan(0.001), + reason: 'layer $i, frame $frame', + ); + expect( + (after.bottomRight - before[i].bottomRight).distance, + lessThan(0.001), + reason: 'layer $i, frame $frame', + ); } } expect(state.selectedLayers.length, select ? layers.length : 0); From b04b475e494e5b442b359b2348f09d13c81fa520 Mon Sep 17 00:00:00 2001 From: hm21 Date: Sat, 24 Oct 2026 10:22:29 +0200 Subject: [PATCH 3/3] test(layers): describe the selection test without the removed hit notifier --- .../main_editor/text_layer_selection_position_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/features/main_editor/text_layer_selection_position_test.dart b/test/features/main_editor/text_layer_selection_position_test.dart index fc1a0b58..1a856e7f 100644 --- a/test/features/main_editor/text_layer_selection_position_test.dart +++ b/test/features/main_editor/text_layer_selection_position_test.dart @@ -27,7 +27,7 @@ void main() { ), ); - // The companion app anchors text by its top-left corner. + // Text anchored by its top-left corner instead of its center. final topLeftConfigs = configs.copyWith( textEditor: const TextEditorConfigs(layerFractionalOffset: Offset.zero), ); @@ -107,8 +107,8 @@ void main() { ); } - // The first hit changes the hit notifier. Later hits must also - // keep the content in place when selecting and deselecting it. + // The first tap selects the layer. Neither it nor the taps after + // it may move the content, on any frame. for (var attempt = 0; attempt < 3; attempt++) { final gesture = await tester.startGesture( before.center,