From 68d81c0b1945ae4a5d0aec4e50e6b896c3e61cdd Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 9 Oct 2026 13:47:44 +0200 Subject: [PATCH 1/2] feat(main-editor): reorder layers within the current history step moveLayerListPosition always recorded a new history step. A video timeline that moves a layer to another row already records its step when the drag starts, so it reordered activeLayers in place instead, and nothing redrew the layers: setLayerTimeline returns early when the drag leaves the time unchanged, so it sends no UI update either. The canvas kept the old stacking order, e.g. a censor area still blurred a sticker it had been moved below. moveLayerListPosition now takes skipUpdateHistory. With it, the layers are reordered in the current step and redrawn through the layer stream, as replaceLayer does with the same flag. The bounds check is fixed on the way: newIndex is the position after the layer is removed, so newIndex == layers.length used to pass the check and then throw a RangeError on insert. It is now ignored like any other out-of-range index. --- CHANGELOG.md | 4 + lib/features/main_editor/main_editor.dart | 32 +++-- pubspec.yaml | 2 +- .../move_layer_list_position_test.dart | 133 ++++++++++++++++++ 4 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 test/features/main_editor/move_layer_list_position_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index da1575a8..50d2a544 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, e.g. when a drag recorded its step on start; the layers are redrawn 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..6d48f042 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -2603,20 +2603,34 @@ class ProImageEditorState extends State /// /// - `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}) { + /// + /// 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) { + _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..dadb9270 --- /dev/null +++ b/test/features/main_editor/move_layer_list_position_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.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('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)); + }); + }); +} From 4df62359f00bb68a50890871714562b2a7ad79c2 Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 9 Oct 2026 13:59:36 +0200 Subject: [PATCH 2/2] fix(main-editor): invalidate the step's screenshot on a reorder without history With skipUpdateHistory, moveLayerListPosition reordered the current history step but left its background screenshot alone. That screenshot was taken when the step was recorded, e.g. when a drag started, so doneEditing returned the image with the layers in their old order. Mark the active screenshot broken so the final capture renders the layers again, as updateBackgroundImage does without history. Also document that newIndex counts after the removal and shorten the changelog entry. --- CHANGELOG.md | 2 +- lib/features/main_editor/main_editor.dart | 6 ++++- .../move_layer_list_position_test.dart | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d2a544..6c2a8453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 14.11.0 -- **FEAT**(main-editor): `moveLayerListPosition` takes `skipUpdateHistory` to reorder layers within the current history step, e.g. when a drag recorded its step on start; the layers are redrawn in their new order. +- **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 diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index 6d48f042..b518bdb0 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -2602,7 +2602,8 @@ 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. + /// - `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 @@ -2626,6 +2627,9 @@ class ProImageEditorState extends State layers.insert(newIndex, layers.removeAt(oldIndex)); 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; } diff --git a/test/features/main_editor/move_layer_list_position_test.dart b/test/features/main_editor/move_layer_list_position_test.dart index dadb9270..a93b9038 100644 --- a/test/features/main_editor/move_layer_list_position_test.dart +++ b/test/features/main_editor/move_layer_list_position_test.dart @@ -1,6 +1,7 @@ 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'; @@ -119,6 +120,31 @@ void main() { 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;