Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 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).

## 13.2.1
- **FIX**(text-editor): Stop the rounded background text from growing and reflowing the moment editing completes (#849). The editing preview now reserves the same hit-box padding as the finished layer and wraps the background at the same column as the editable text, so the background no longer shifts or changes line count when editing ends.

Expand Down
29 changes: 28 additions & 1 deletion lib/features/main_editor/main_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,14 @@ class ProImageEditorState extends State<ProImageEditor>
/// interaction.
/// Important: No screenshot is taken at this point; it will be captured
/// after the layer interaction is completed.
if (hasSelectedLayers) addHistory(blockCaptureScreenshot: true);
///
/// The `!isLayerBeingTransformed` guard prevents a second history entry
/// when Flutter's [ScaleGestureRecognizer] restarts the gesture after a
/// mid-gesture pointer change (e.g. a third finger touching down), which
/// would otherwise corrupt the history/screenshot stack (issue #850).
if (hasSelectedLayers && !isLayerBeingTransformed) {
addHistory(blockCaptureScreenshot: true);
}
_checkInteractiveViewer();
isLayerBeingTransformed = hasSelectedLayers;
layerInteractionManager.onScaleStart(
Expand Down Expand Up @@ -1572,6 +1579,26 @@ class ProImageEditorState extends State<ProImageEditor>
/// lines and flags.
void _onScaleEnd(ScaleEndDetails details) async {
mainEditorCallbacks?.handleScaleEnd(details);

/// Flutter's [ScaleGestureRecognizer] fires a spurious `onScaleEnd`
/// (immediately followed by a fresh `onScaleStart`) whenever the number of
/// active pointers changes mid-gesture — for example when a third finger
/// touches the screen while two fingers are still rotating a layer. In that
/// case the fingers are not actually lifted ([details.pointerCount] > 0),
/// so we must not run the destructive finish logic below (removing the last
/// screenshot, mutating the history stack and clearing the selection).
/// Doing so corrupts the history/screenshot stack and, on iOS, crashes the
/// editor with a `_dependents.isEmpty` framework assertion (issue #850).
///
/// The interactive viewer still needs the event to keep its own
/// start/end pairing balanced.
if (details.pointerCount > 0) {
if (!hasSelectedLayers && !_layerDragSelectionService.isActive) {
interactiveViewer.currentState?.onScaleEnd(details);
}
return;
}

if (selectedLayers.isNotEmpty) {
mainEditorCallbacks?.handleLayerInteractionEnd(List.of(selectedLayers));
}
Expand Down
133 changes: 133 additions & 0 deletions test/features/main_editor/multitouch_third_finger_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Flutter imports:
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// Package imports:
import 'package:flutter_test/flutter_test.dart';

// Project imports:
import 'package:pro_image_editor/pro_image_editor.dart';

import '../../mock/mock_image.dart';

/// Reproduction for issue #850:
/// "Editor freezes after a three-finger gesture while rotating an emoji layer".
///
/// Steps from the report:
/// 1. Add an emoji layer.
/// 2. Rotate it with two fingers (keep both fingers down).
/// 3. Tap the screen with a third finger.
///
/// On iOS this throws `assert(_dependents.isEmpty)` from
/// `InheritedElement.debugDeactivated()` and freezes the editor.
void main() {
const configs = ProImageEditorConfigs(
progressIndicatorConfigs: ProgressIndicatorConfigs(
widgets: ProgressIndicatorWidgets(
circularProgressIndicator: SizedBox.shrink(),
),
),
imageGeneration: ImageGenerationConfigs(
enableIsolateGeneration: false,
enableBackgroundGeneration: false,
),
);

Future<ProImageEditorState> pumpEditor(WidgetTester tester) async {
final key = GlobalKey<ProImageEditorState>();
await tester.pumpWidget(
MaterialApp(
home: ProImageEditor.memory(
mockMemoryImage,
key: key,
configs: configs,
callbacks: ProImageEditorCallbacks(
onImageEditingComplete: (Uint8List bytes) async {},
),
),
),
);
await tester.pumpAndSettle();
return key.currentState!;
}

testWidgets(
'adding a third finger while rotating an emoji layer does not throw',
(tester) async {
final state = await pumpEditor(tester);

final layer = EmojiLayer(
emoji: '😀',
offset: Offset.zero,
scale: 1,
rotation: 0,
);
state.addLayer(
layer,
autoCorrectZoomOffset: false,
autoCorrectZoomScale: false,
);
await tester.pump();

// Select the emoji so the two-finger gesture scales/rotates the layer.
state.layerInteractionManager
..clearSelectedLayers()
..addSelectedLayer(layer.id);
await tester.pump();

final Offset center = tester.getCenter(find.byType(ProImageEditor));

// --- Two fingers down: start a rotate/scale gesture on the layer. ---
final TestGesture finger1 = await tester.startGesture(
center + const Offset(-40, 0),
pointer: 1,
);
final TestGesture finger2 = await tester.startGesture(
center + const Offset(40, 0),
pointer: 2,
);
await tester.pump();

// Move both fingers to rotate the layer (rotate around the center).
for (int i = 1; i <= 6; i++) {
final double angle = (pi / 12) * i;
await finger1.moveTo(
center + Offset(-40 * cos(angle), -40 * sin(angle)),
);
await finger2.moveTo(center + Offset(40 * cos(angle), 40 * sin(angle)));
await tester.pump(const Duration(milliseconds: 16));
}

// --- Third finger taps while the first two are still held down. ---
final TestGesture finger3 = await tester.startGesture(
center + const Offset(0, 120),
pointer: 3,
);
await tester.pump(const Duration(milliseconds: 16));

// A tap = quick lift of the third finger.
await finger3.up();
await tester.pump(const Duration(milliseconds: 16));

// Continue moving the two remaining fingers a little.
await finger1.moveBy(const Offset(-5, -5));
await finger2.moveBy(const Offset(5, 5));
await tester.pump(const Duration(milliseconds: 16));

// Lift the remaining fingers.
await finger1.up();
await finger2.up();
await tester.pumpAndSettle();

expect(
tester.takeException(),
isNull,
reason:
'Adding a third finger mid-rotation must not throw '
'(issue #850).',
);
},
);
}
Loading