diff --git a/CHANGELOG.md b/CHANGELOG.md index da1575a8..6c2a8453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 14.11.0 +- **FEAT**(main-editor): `moveLayerListPosition` takes `skipUpdateHistory` to reorder layers within the current history step and redraws them in their new order. +- **FIX**(main-editor): `moveLayerListPosition` ignores a `newIndex` past the last layer instead of throwing a `RangeError`. + ## 14.10.2 - **FIX**(main-editor): `onOpenSubEditor`, `onStartCloseSubEditor` and `onEndCloseSubEditor` report the tune editor as `SubEditor.tune` instead of `SubEditor.unknown`. diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index 2a14a1a0..b518bdb0 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -2602,21 +2602,39 @@ class ProImageEditorState extends State /// Moves a layer in the list to a new position. /// /// - `oldIndex` is the current index of the layer. - /// - `newIndex` is the desired index to move the layer to. - void moveLayerListPosition({required int oldIndex, required int newIndex}) { + /// - `newIndex` is the index the layer ends up at, counted after it is + /// removed. An index outside the layer list is ignored. + /// + /// Pass [skipUpdateHistory] to reorder the current history step instead of + /// recording a new one, e.g. when a drag already recorded its step when it + /// started, as with [setLayerTimeline]. The layers are redrawn in their new + /// order either way. + void moveLayerListPosition({ + required int oldIndex, + required int newIndex, + bool skipUpdateHistory = false, + }) { if (oldIndex == newIndex || oldIndex < 0 || newIndex < 0) return; + if (oldIndex >= activeLayers.length || newIndex >= activeLayers.length) { + return; + } - final layers = _layerCopyManager.copyLayerList(activeLayers); - - if (oldIndex < layers.length && newIndex <= layers.length) { - final item = layers.removeAt(oldIndex); + final layers = skipUpdateHistory + ? activeLayers + : _layerCopyManager.copyLayerList(activeLayers); - // Insert directly at newIndex, no adjustment needed - layers.insert(newIndex, item); + // Insert directly at newIndex, no adjustment needed + layers.insert(newIndex, layers.removeAt(oldIndex)); - addHistory(layers: layers); - setState(() {}); + if (skipUpdateHistory) { + // The step's background screenshot still shows the old order, so let + // the final capture take a new one. + stateManager.activeScreenshot?.broken = true; + _controllers.uiLayerCtrl.add(null); + return; } + addHistory(layers: layers); + setState(() {}); } /// Moves the given layer one step forward in the stack. diff --git a/pubspec.yaml b/pubspec.yaml index 25306747..6b894f1f 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: 14.10.2 +version: 14.11.0 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/ diff --git a/test/features/main_editor/move_layer_list_position_test.dart b/test/features/main_editor/move_layer_list_position_test.dart new file mode 100644 index 00000000..a93b9038 --- /dev/null +++ b/test/features/main_editor/move_layer_list_position_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/core/models/multi_threading/thread_capture_model.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/widgets/layer/layer_widget.dart'; + +import '../../mock/mock_image.dart'; + +void main() { + const configs = ProImageEditorConfigs( + progressIndicatorConfigs: ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: ImageGenerationConfigs( + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + ), + ); + + Future pumpEditor(WidgetTester tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: configs, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: (Uint8List bytes) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; + } + + /// Adds three emoji layers, bottom-most first. + Future<(ProImageEditorState, List)> pumpThreeLayers( + WidgetTester tester, + ) async { + final state = await pumpEditor(tester); + final layers = [ + EmojiLayer(emoji: '🍎'), + EmojiLayer(emoji: '🍌'), + EmojiLayer(emoji: '🍒'), + ]; + for (final layer in layers) { + state.addLayer( + layer, + blockSelectLayer: true, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + } + await tester.pumpAndSettle(); + return (state, [for (final layer in layers) layer.id]); + } + + /// The ids of the layers the editor draws, bottom-most first. + List drawnLayerIds(WidgetTester tester) => [ + for (final widget in tester.widgetList( + find.byType(LayerWidget), + )) + widget.layer.id, + ]; + + group('moveLayerListPosition', () { + testWidgets('records a history step and redraws the layers', ( + tester, + ) async { + final (state, ids) = await pumpThreeLayers(tester); + final steps = state.stateManager.stateHistory.length; + + state.moveLayerListPosition(oldIndex: 2, newIndex: 0); + await tester.pump(); + + expect(drawnLayerIds(tester), [ids[2], ids[0], ids[1]]); + expect(state.stateManager.stateHistory, hasLength(steps + 1)); + }); + + testWidgets('with skipUpdateHistory redraws the layers in their new ' + 'order without a history step', (tester) async { + final (state, ids) = await pumpThreeLayers(tester); + expect(drawnLayerIds(tester), ids); + final steps = state.stateManager.stateHistory.length; + + state.moveLayerListPosition( + oldIndex: 2, + newIndex: 0, + skipUpdateHistory: true, + ); + await tester.pumpAndSettle(); + + expect(drawnLayerIds(tester), [ids[2], ids[0], ids[1]]); + expect(state.stateManager.stateHistory, hasLength(steps)); + }); + + testWidgets('with skipUpdateHistory after addHistory undoes in one step', ( + tester, + ) async { + final (state, ids) = await pumpThreeLayers(tester); + + state + ..addHistory() + ..moveLayerListPosition( + oldIndex: 0, + newIndex: 2, + skipUpdateHistory: true, + ); + await tester.pumpAndSettle(); + expect(drawnLayerIds(tester), [ids[1], ids[2], ids[0]]); + + state.undoAction(); + await tester.pump(); + + expect(drawnLayerIds(tester), ids); + }); + + testWidgets('with skipUpdateHistory invalidates the screenshot of the ' + 'current step only', (tester) async { + final (state, _) = await pumpThreeLayers(tester); + // Background generation is off here, so stand in one screenshot per + // recorded step, as the editor keeps them with it on. + final screenshots = [ + for (var i = 1; i < state.stateManager.stateHistory.length; i++) + ThreadCaptureState(), + ]; + state.stateManager.screenshots.addAll(screenshots); + + state.moveLayerListPosition( + oldIndex: 2, + newIndex: 0, + skipUpdateHistory: true, + ); + await tester.pumpAndSettle(); + + expect(state.stateManager.activeScreenshot, same(screenshots.last)); + expect( + [for (final item in screenshots) item.broken], + [false, false, true], + ); + }); + + testWidgets('ignores an index past the last layer', (tester) async { + final (state, ids) = await pumpThreeLayers(tester); + final steps = state.stateManager.stateHistory.length; + + state.moveLayerListPosition(oldIndex: 0, newIndex: 3); + await tester.pump(); + + expect(drawnLayerIds(tester), ids); + expect(state.stateManager.stateHistory, hasLength(steps)); + }); + }); +}