From a549f4e8578ff0602469ee80af02424bd6d561b8 Mon Sep 17 00:00:00 2001 From: hm21 Date: Sat, 31 Oct 2026 09:17:38 +0100 Subject: [PATCH 1/2] feat(layers): per-layer move and scale limits Add LayerInteraction.bounds, a function of the main editor's body size that returns the area a layer stays inside, and LayerInteraction.minScale and maxScale, which replace the scale limits of the layer's type for that layer. A dragged layer stops at the edge of its bounds and follows the finger again once the finger is back where it stopped. Pinched, rotated or scaled with the selection button at an edge, it is pushed inwards, does not grow past the bounds and moves back as it shrinks within the same gesture. A layer larger than its bounds is shrunk to fit, but not below its minScale. The layer is measured as it is drawn: at its scale and turned by its rotation. A helper line or guide the bounds move the layer off is hidden. The desktop scroll and keyboard shortcuts keep the limits too, and LayerInteractionManager.applyLayerLimits applies them to a layer changed by code. minScale and maxScale are exported with the layer (minified as n and x); bounds, a function, is not. Importing a history now merges each step's interaction settings over the previous step's instead of replacing them, so a step that changes one setting no longer resets the others. --- CHANGELOG.md | 4 + .../lib/features/layer/layer_group_page.dart | 7 + .../features/layer/layer_limits_example.dart | 143 +++++ lib/core/models/layers/layer.dart | 11 +- lib/core/models/layers/layer_interaction.dart | 112 +++- lib/features/main_editor/main_editor.dart | 9 + .../services/desktop_interaction_manager.dart | 8 + .../services/layer_interaction_manager.dart | 222 ++++++- .../constants/minified_keys.dart | 2 + .../import_export/import_state_history.dart | 16 +- pubspec.yaml | 2 +- .../models/layers/layer_interaction_test.dart | 195 +++++++ .../layer_interaction_limits_test.dart | 546 ++++++++++++++++++ .../layer_interaction_history_test.dart | 147 +++++ 14 files changed, 1392 insertions(+), 32 deletions(-) create mode 100644 example/lib/features/layer/layer_limits_example.dart create mode 100644 test/core/models/layers/layer_interaction_test.dart create mode 100644 test/features/main_editor/layer_interaction_limits_test.dart create mode 100644 test/shared/services/import_export/layer_interaction_history_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 544e9a380..f2b2049d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 14.23.0 +- **FEAT**(layers): Add `LayerInteraction.bounds` to keep a layer inside an area while it is moved, pinched or rotated, and `LayerInteraction.minScale` / `maxScale` to limit how far one layer scales; `LayerInteractionManager.applyLayerLimits` applies them to a layer changed by code. +- **FIX**(import-export): A history step that changes one interaction setting of a layer no longer resets its other settings on import. + ## 14.22.0 - **FEAT**(layers): Add `LayerAnimationType.spin` and `LayerAnimationType.flip` (around a `FlipAxis`) to turn a layer by `LayerAnimation.turns`; a `switch` over `LayerAnimationType` must handle the new values. diff --git a/example/lib/features/layer/layer_group_page.dart b/example/lib/features/layer/layer_group_page.dart index 0873243b1..db4d3b4d5 100644 --- a/example/lib/features/layer/layer_group_page.dart +++ b/example/lib/features/layer/layer_group_page.dart @@ -3,6 +3,7 @@ import 'package:material_ui/material_ui.dart'; import '/features/layer/inline_text_example.dart'; import '/features/layer/layer_export_example.dart'; import '/features/layer/layer_grouping_example.dart'; +import '/features/layer/layer_limits_example.dart'; import '/features/layer/layer_rasterizer_example.dart'; import '/features/layer/layer_select_design_example.dart'; import '/features/layer/selectable_layer_example.dart'; @@ -46,6 +47,12 @@ class _LayerGroupPageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _openExample(const SelectableLayerExample()), ), + ListTile( + leading: const Icon(Icons.fit_screen_outlined), + title: const Text('Move and Scale Limits'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openExample(const LayerLimitsExample()), + ), ListTile( leading: const Icon(Icons.crop_free), title: const Text('Resizable Text Boxes'), diff --git a/example/lib/features/layer/layer_limits_example.dart b/example/lib/features/layer/layer_limits_example.dart new file mode 100644 index 000000000..2b516d4eb --- /dev/null +++ b/example/lib/features/layer/layer_limits_example.dart @@ -0,0 +1,143 @@ +// Flutter imports: +import 'package:material_ui/material_ui.dart'; + +// Package imports: +import 'package:pro_image_editor/core/models/layers/layer_interaction.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +// Project imports: +import '/core/constants/example_constants.dart'; +import '/core/mixin/example_helper.dart'; + +/// A widget that demonstrates layers that stay inside an area and within a +/// range of sizes. +/// +/// The editor opens with an emoji and a card inside a framed area. Drag, +/// pinch or turn them: they stop at the edges of the frame instead of +/// leaving it. The card does not turn and only scales between half and +/// twice its size. +/// +/// Example usage: +/// ```dart +/// LayerLimitsExample(); +/// ``` +class LayerLimitsExample extends StatefulWidget { + /// Creates a new [LayerLimitsExample] widget. + const LayerLimitsExample({super.key}); + + @override + State createState() => _LayerLimitsExampleState(); +} + +/// The state for the [LayerLimitsExample] widget. +class _LayerLimitsExampleState extends State + with ExampleHelperState { + @override + void initState() { + super.initState(); + preCacheImage(assetPath: kImageEditorExampleAssetPath); + } + + /// The area the layers stay inside, in the logical pixels of the editor's + /// body. It is derived from the size of the body, so it follows it when + /// the device turns or the window is resized. + static Rect _safeArea(Size editorBodySize) => Rect.fromLTRB( + editorBodySize.width * 0.1, + editorBodySize.height * 0.15, + editorBodySize.width * 0.9, + editorBodySize.height * 0.75, + ); + + void _onAfterViewInit() { + editorKey.currentState! + ..addLayer( + EmojiLayer( + emoji: '🎯', + offset: const Offset(0, -60), + interaction: LayerInteraction(bounds: _safeArea), + ), + ) + ..addLayer( + WidgetLayer( + offset: const Offset(0, 60), + widget: _buildCard(), + interaction: LayerInteraction( + enableRotate: false, + minScale: 0.5, + maxScale: 2, + bounds: _safeArea, + ), + ), + ); + } + + Widget _buildCard() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Stays inside', + style: TextStyle(color: Colors.black, fontWeight: FontWeight.w600), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!isPreCached) return const PrepareImageWidget(); + + return ProImageEditor.asset( + kImageEditorExampleAssetPath, + key: editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingStarted: onImageEditingStarted, + onImageEditingComplete: onImageEditingComplete, + onCloseEditor: (editorMode) => onCloseEditor( + editorMode: editorMode, + enablePop: !isDesktopMode(context), + ), + mainEditorCallbacks: MainEditorCallbacks( + onAfterViewInit: _onAfterViewInit, + helperLines: HelperLinesCallbacks(onLineHit: vibrateLineHit), + ), + ), + configs: ProImageEditorConfigs( + designMode: platformDesignMode, + mainEditor: MainEditorConfigs( + enableCloseButton: !isDesktopMode(context), + widgets: MainEditorWidgets( + bodyItems: (editor, rebuildStream) => [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => editor.isSubEditorOpen + ? const SizedBox.shrink() + : _buildSafeAreaFrame(editor.sizesManager.bodySize), + ), + ], + ), + ), + ), + ); + } + + /// Outlines [_safeArea], so it shows where the layers stop. + Widget _buildSafeAreaFrame(Size editorBodySize) { + if (editorBodySize.isEmpty || !editorBodySize.isFinite) { + return const SizedBox.shrink(); + } + return Positioned.fromRect( + rect: _safeArea(editorBodySize), + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.white54, width: 2), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ); + } +} diff --git a/lib/core/models/layers/layer.dart b/lib/core/models/layers/layer.dart index 6fbce1b6c..7f38f68f2 100644 --- a/lib/core/models/layers/layer.dart +++ b/lib/core/models/layers/layer.dart @@ -675,6 +675,11 @@ class Layer { int maxDecimalPlaces = kMaxSafeDecimalPlaces, bool enableMinify = false, }) { + // Empty when only [LayerInteraction.bounds] changed, which is not stored. + final interactionChanges = interaction.toMapFromReference( + layer.interaction, + enableMinify: enableMinify, + ); return { 'id': id, if (layer.offset.dx != offset.dx) @@ -687,11 +692,7 @@ class Layer { if (layer.flipX != flipX) 'flipX': flipX.minify(enableMinify), if (layer.flipY != flipY) 'flipY': flipY.minify(enableMinify), if (!mapIsEqual(layer.meta, meta)) 'meta': meta, - if (layer.interaction != interaction) - 'interaction': interaction.toMapFromReference( - layer.interaction, - enableMinify: enableMinify, - ), + if (interactionChanges.isNotEmpty) 'interaction': interactionChanges, if (layer.boxConstraints != boxConstraints) 'boxConstraints': boxConstraints!.toMap( maxDecimalPlaces: maxDecimalPlaces, diff --git a/lib/core/models/layers/layer_interaction.dart b/lib/core/models/layers/layer_interaction.dart index 74cfcff62..0102136be 100644 --- a/lib/core/models/layers/layer_interaction.dart +++ b/lib/core/models/layers/layer_interaction.dart @@ -1,30 +1,43 @@ +import 'dart:ui'; + import '../../../shared/extensions/export_bool_extension.dart'; import '../../../shared/utils/parser/bool_parser.dart'; +import '../../../shared/utils/parser/double_parser.dart'; /// A class representing the interaction settings for a layer. /// /// The `LayerInteraction` class defines the enablement of various interaction /// capabilities for a layer, including moving, scaling, rotating, and -/// selection. +/// selection, and the limits a gesture keeps the layer within: how far it may +/// be scaled and where it may be placed. class LayerInteraction { /// Creates a new instance of [LayerInteraction] with customizable /// interaction settings. /// /// All interaction settings are enabled by default unless specified - /// otherwise. + /// otherwise, and no limits are set. /// /// - [enableMove]: Enables the ability to move the layer. /// - [enableScale]: Enables the ability to scale the layer. /// - [enableRotate]: Enables the ability to rotate the layer. /// - [enableSelection]: Enables the ability to select the layer. /// - [enableEdit]: Enables the ability to edit the layer. + /// - [minScale]: The smallest scale a gesture may give the layer. + /// - [maxScale]: The largest scale a gesture may give the layer. + /// - [bounds]: The area the layer stays inside. LayerInteraction({ this.enableMove = true, this.enableScale = true, this.enableRotate = true, this.enableSelection = true, this.enableEdit = true, - }); + this.minScale, + this.maxScale, + this.bounds, + }) : assert( + minScale == null || maxScale == null || maxScale >= minScale, + 'maxScale must be greater than or equal to minScale', + ); /// Creates a [LayerInteraction] instance from a [Map]. /// @@ -32,6 +45,8 @@ class LayerInteraction { /// the property names. /// - [keyConverter]: An optional function to convert keys from the map to /// match the property names. Defaults to identity mapping. + /// + /// [bounds] is not stored, so the instance has none. factory LayerInteraction.fromMap( Map map, { Function(String key)? keyConverter, @@ -59,6 +74,8 @@ class LayerInteraction { map[keyConverter('enableEdit')], fallback: true, ), + minScale: tryParseDouble(map[keyConverter('minScale')]), + maxScale: tryParseDouble(map[keyConverter('maxScale')]), ); } @@ -100,6 +117,56 @@ class LayerInteraction { /// TextLayers or WidgetLayers when the onTapEditSticker callback is set. bool enableEdit; + /// The smallest `Layer.scale` a gesture may give the layer, or `null` for + /// the limit of its type, such as `StickerEditorConfigs.minScale` for a + /// `WidgetLayer`. + /// + /// It replaces the limit of the type for this layer, in either direction, + /// and it applies to pinching the layer, dragging the scale button of its + /// selection and the scroll and keyboard shortcuts on a desktop. [bounds] + /// never shrink the layer below it. + /// + /// It is stored with the layer when the editor state is exported. + double? minScale; + + /// The largest `Layer.scale` a gesture may give the layer, or `null` for + /// the limit of its type, such as `StickerEditorConfigs.maxScale` for a + /// `WidgetLayer`. + /// + /// It replaces the limit of the type for this layer, in either direction, + /// and it applies where [minScale] does. + /// + /// It is stored with the layer when the editor state is exported. + double? maxScale; + + /// The area the layer stays inside while it is moved, scaled or rotated, + /// or `null` to let it go anywhere. + /// + /// The function receives the size of the main editor's body and returns + /// the area in the logical pixels of that body, measured from its top left + /// corner, like `TextEditorConfigs.layerBounds`. It is called whenever the + /// layer is moved, so the area can follow anything that changes it, such + /// as the aspect ratio of an export. + /// + /// The whole layer stays inside: the box it is drawn in, at its scale and + /// turned by its rotation, without the selection frame. Dragged towards an + /// edge, it stops there while the finger keeps going, and moves again once + /// the finger is back where it stopped. Pinched or rotated at an edge, it is + /// pushed inwards, and it moves back as it gets smaller within the same + /// gesture. A layer larger than the area is shrunk to fit it, even when + /// [enableScale] is off, but never below its [minScale]; one that does not + /// fit even then is centered in the area along the side it is too big for. + /// The scroll and keyboard shortcuts on a desktop keep it inside as well. + /// + /// The area applies only when the layer is moved: a layer that is added or + /// changed outside it by code stays where it is until then. Each layer of + /// a multi-selection stops at its own area. + /// + /// A function cannot be stored, so an exported state does not hold the + /// area: set it again on the layers after an import. Copies of the layer, + /// such as the ones in the history, share it. + Rect Function(Size editorBodySize)? bounds; + /// Enables or disables all interaction capabilities for a layer. /// /// The following interactions can be enabled or disabled: @@ -109,7 +176,8 @@ class LayerInteraction { /// - Rotating /// - Selection /// - /// Each interaction is controlled by the `value` parameter. + /// Each interaction is controlled by the `value` parameter. The limits, + /// [minScale], [maxScale] and [bounds], stay as they are. /// /// Parameters: /// - `value` (bool): The value to set for enabling or disabling the @@ -132,12 +200,18 @@ class LayerInteraction { /// setting. /// - [enableEdit]: if provided, overrides the current `enableEdit` /// setting. + /// - [minScale], [maxScale] and [bounds]: If provided, override the current + /// limits. A `null` keeps the current limit, so to remove one, set the + /// field to `null` on the copy. LayerInteraction copyWith({ bool? enableMove, bool? enableScale, bool? enableRotate, bool? enableSelection, bool? enableEdit, + double? minScale, + double? maxScale, + Rect Function(Size editorBodySize)? bounds, }) { return LayerInteraction( enableMove: enableMove ?? this.enableMove, @@ -145,13 +219,17 @@ class LayerInteraction { enableRotate: enableRotate ?? this.enableRotate, enableSelection: enableSelection ?? this.enableSelection, enableEdit: enableEdit ?? this.enableEdit, + minScale: minScale ?? this.minScale, + maxScale: maxScale ?? this.maxScale, + bounds: bounds ?? this.bounds, ); } /// Converts this [LayerInteraction] instance into a [Map]. /// /// Returns a map representation of the interaction settings with keys - /// corresponding to the property names. + /// corresponding to the property names. [minScale] and [maxScale] are + /// written only when they are set; [bounds] never is. Map toMap({bool enableMinify = false}) { return { 'enableMove': enableMove.minify(enableMinify), @@ -159,6 +237,8 @@ class LayerInteraction { 'enableRotate': enableRotate.minify(enableMinify), 'enableSelection': enableSelection.minify(enableMinify), 'enableEdit': enableEdit.minify(enableMinify), + if (minScale != null) 'minScale': minScale, + if (maxScale != null) 'maxScale': maxScale, }; } @@ -166,7 +246,8 @@ class LayerInteraction { /// it to another reference [LayerInteraction]. /// /// Only includes the properties where the current instance's values differ - /// from the [interaction] reference. + /// from the [interaction] reference. A scale limit that was removed is + /// written as `null`. Map toMapFromReference( LayerInteraction interaction, { bool enableMinify = false, @@ -182,6 +263,8 @@ class LayerInteraction { 'enableSelection': enableSelection.minify(enableMinify), if (interaction.enableEdit != enableEdit) 'enableEdit': enableEdit.minify(enableMinify), + if (interaction.minScale != minScale) 'minScale': minScale, + if (interaction.maxScale != maxScale) 'maxScale': maxScale, }; } @@ -192,13 +275,16 @@ class LayerInteraction { 'enableScale: $enableScale, ' 'enableRotate: $enableRotate, ' 'enableSelection: $enableSelection, ' - 'enableEdit: $enableEdit)'; + 'enableEdit: $enableEdit, ' + 'minScale: $minScale, ' + 'maxScale: $maxScale, ' + 'bounds: ${bounds == null ? 'none' : 'set'})'; } /// Compares this [LayerInteraction] instance with another for equality. /// /// Two [LayerInteraction] instances are considered equal if all their - /// properties match. + /// properties match. [bounds] match when they are the same function. @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -208,7 +294,10 @@ class LayerInteraction { other.enableScale == enableScale && other.enableRotate == enableRotate && other.enableSelection == enableSelection && - other.enableEdit == enableEdit; + other.enableEdit == enableEdit && + other.minScale == minScale && + other.maxScale == maxScale && + other.bounds == bounds; } /// Returns a hash code for this [LayerInteraction] instance. @@ -218,6 +307,9 @@ class LayerInteraction { enableScale.hashCode ^ enableRotate.hashCode ^ enableSelection.hashCode ^ - enableEdit.hashCode; + enableEdit.hashCode ^ + minScale.hashCode ^ + maxScale.hashCode ^ + bounds.hashCode; } } diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index 721cf1a62..be9f697b7 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -624,6 +624,12 @@ class ProImageEditorState extends State onUpdateUI: mainEditorCallbacks?.handleUpdateUI, setState: setState, keyframeTime: () => _videoController?.playTimeNotifier.value, + applyLayerLimits: (layer, laidOutScale) => + layerInteractionManager.applyLayerLimits( + layer, + editorBodySize: sizesManager.bodySize, + laidOutScale: laidOutScale, + ), ); sizesManager = SizesManager(configs: configs, context: context); layerInteractionManager.scaleDebounce = Debounce( @@ -1525,6 +1531,9 @@ class ProImageEditorState extends State /// mid-gesture pointer change (e.g. a third finger touching down), which /// would otherwise corrupt the history/screenshot stack (issue #850). if (hasSelectedLayers && !isLayerBeingTransformed) { + // Before the history step copies the layers, which are laid out only a + // frame later, and before keyframes place them elsewhere. + layerInteractionManager.measureLayerSizes(selectedLayers); addHistory(blockCaptureScreenshot: true); _startKeyframeEdit(); } diff --git a/lib/features/main_editor/services/desktop_interaction_manager.dart b/lib/features/main_editor/services/desktop_interaction_manager.dart index 6b0c59b44..6778aee99 100644 --- a/lib/features/main_editor/services/desktop_interaction_manager.dart +++ b/lib/features/main_editor/services/desktop_interaction_manager.dart @@ -43,6 +43,7 @@ class DesktopInteractionManager { required this.configs, required this.callbacks, this.keyframeTime, + this.applyLayerLimits, }); /// The build context associated with the desktop interaction manager. @@ -77,20 +78,27 @@ class DesktopInteractionManager { /// is written to, or `null` outside the video editor. final Duration? Function()? keyframeTime; + /// Keeps a layer a shortcut changed within the limits of its + /// [Layer.interaction], given the scale it was laid out at before. + final void Function(Layer layer, double laidOutScale)? applyLayerLimits; + /// Applies [change] to [layer]. A layer with keyframes is first laid out at /// the placement they give it at [keyframeTime], and the changed placement /// is written back to the keyframe there, so the change shows on screen. /// A [change] that leaves the layer where it was, such as a scroll while /// zooming is off, adds no keyframe. void _transformLayer(Layer layer, void Function() change) { + final laidOutScale = layer.scale; final time = keyframeTime?.call(); if (time == null || !layer.hasKeyframes) { change(); + applyLayerLimits?.call(layer, laidOutScale); return; } layer.applyKeyframePlacement(time); final before = layer.restPlacement; change(); + applyLayerLimits?.call(layer, laidOutScale); if (layer.restPlacement == before) return; layer.setKeyframeAt( time, diff --git a/lib/features/main_editor/services/layer_interaction_manager.dart b/lib/features/main_editor/services/layer_interaction_manager.dart index 87744169b..4151b9570 100644 --- a/lib/features/main_editor/services/layer_interaction_manager.dart +++ b/lib/features/main_editor/services/layer_interaction_manager.dart @@ -499,8 +499,16 @@ class LayerInteractionManager { /// Snap may rewrite [Layer.offset] for display; this value keeps accumulating /// the finger/mouse delta so release can be measured from the intended /// position rather than the glued snapped position. + /// + /// It is also where [LayerInteraction.bounds] measure a layer from, so it + /// follows the finger again once the finger is back, and where a pinch + /// found it, so it moves back once it is small enough again. final Map _unconstrainedOffsets = {}; + /// The size of each layer with [LayerInteraction.bounds] at a scale of 1, + /// measured by [measureLayerSizes] when the running gesture started. + final Map _unitSizes = {}; + /// Optional override for helper line configuration at runtime. /// /// When set, this takes precedence over [configs.helperLines], allowing @@ -853,7 +861,11 @@ class LayerInteractionManager { /// transformed using a single layer interaction button. if (activeInteractionLayer?.id != layer.id) continue; - Offset layerOffset = layer.offset; + // Turned around where the gesture found the layer, which its bounds + // may have pushed it away from since. + Offset layerOffset = layer.interaction.bounds != null + ? _unconstrainedOffsets[layer.id] ?? layer.offset + : layer.offset; Offset realTouchPosition = (details.localFocalPoint - editorScaleOffset) / editorScaleFactor; @@ -900,6 +912,8 @@ class LayerInteractionManager { editorScaleFactor: editorScaleFactor, ); } + + _keepTransformedLayerInBounds(layer, editorSize); } } @@ -940,6 +954,11 @@ class LayerInteractionManager { (_unconstrainedOffsets[layer.id] ?? layer.offset) + pointerDelta; _unconstrainedOffsets[layer.id] = intended; layer.offset = intended; + } else if (layer.interaction.bounds != null) { + // Moved on from where the finger put it rather than from where its + // bounds stopped it. + layer.offset = + (_unconstrainedOffsets[layer.id] ?? layer.offset) + pointerDelta; } else { layer.offset = Offset( layer.offset.dx + pointerDelta.dx, @@ -950,6 +969,7 @@ class LayerInteractionManager { if (hasMultiSelection || (editorScaleFactor > 1 && helperLineConfigs.isDisabledAtZoom) || _excludePaintFromLayerAlign(layer)) { + _keepMovedLayerInBounds(layer, editorBodySize); continue; } @@ -1187,6 +1207,7 @@ class LayerInteractionManager { ); } } + _keepTransformedLayerInBounds(layer, editorSize); } } @@ -1269,6 +1290,7 @@ class LayerInteractionManager { for (Layer layer in selectedLayers) { _unconstrainedOffsets[layer.id] = layer.offset; + if (!_unitSizes.containsKey(layer.id)) measureLayerSizes([layer]); _baseScaleFactor[layer.id] = layer.scale; _baseAngleFactor[layer.id] = layer.rotation; _snapStartRotation[layer.id] = layer.rotation * 180 / pi; @@ -1326,6 +1348,7 @@ class LayerInteractionManager { _heldAlignXTargets.clear(); _heldAlignYTargets.clear(); _unconstrainedOffsets.clear(); + _unitSizes.clear(); } /// Rotate a layer. @@ -1439,27 +1462,187 @@ class LayerInteractionManager { } void _setMinMaxScaleFactor(ProImageEditorConfigs configs, Layer layer) { - if (layer is PaintLayer) { - layer.scale = layer.scale.clamp( + final (minScale, maxScale) = _scaleLimits(configs, layer); + layer.scale = layer.scale.clamp(minScale, maxScale); + } + + /// The smallest and the largest scale a gesture may give [layer]: the + /// limits of its [LayerInteraction], or else those of its type. + (double, double) _scaleLimits(ProImageEditorConfigs configs, Layer layer) { + final (typeMin, typeMax) = switch (layer) { + PaintLayer() => ( configs.paintEditor.minScale, configs.paintEditor.maxScale, - ); - } else if (layer is TextLayer) { - layer.scale = layer.scale.clamp( - configs.textEditor.minScale, - configs.textEditor.maxScale, - ); - } else if (layer is EmojiLayer) { - layer.scale = layer.scale.clamp( + ), + TextLayer() => (configs.textEditor.minScale, configs.textEditor.maxScale), + EmojiLayer() => ( configs.emojiEditor.minScale, configs.emojiEditor.maxScale, - ); - } else if (layer is WidgetLayer) { - layer.scale = layer.scale.clamp( + ), + WidgetLayer() => ( configs.stickerEditor.minScale, configs.stickerEditor.maxScale, + ), + _ => (double.negativeInfinity, double.infinity), + }; + final interaction = layer.interaction; + var minScale = interaction.minScale ?? typeMin; + var maxScale = interaction.maxScale ?? typeMax; + // A limit of the layer wins over the one of its type it crosses. + if (minScale > maxScale) { + if (interaction.maxScale == null) { + maxScale = minScale; + } else { + minScale = maxScale; + } + } + return (minScale, maxScale); + } + + /// Remembers how large [layers] are laid out now, so their + /// [LayerInteraction.bounds] can hold them at any scale and rotation a + /// gesture gives them before they are laid out again. + /// + /// The editor calls this when a gesture starts, before the history step it + /// adds copies the layers, as the copies are laid out only a frame later. + /// The sizes are kept until [onScaleEnd]. Layers without bounds are + /// skipped. + void measureLayerSizes(Iterable layers) { + for (final layer in layers) { + if (layer.interaction.bounds == null) continue; + final size = layer.renderSize; + if (size == null || size.isEmpty || layer.scale <= 0) continue; + _unitSizes[layer.id] = size / layer.scale; + } + } + + /// Keeps [layer] within the limits of its [Layer.interaction]: scales it + /// into [LayerInteraction.minScale] and [LayerInteraction.maxScale], and + /// moves it inside [LayerInteraction.bounds], shrinking it first when it is + /// larger than they are. + /// + /// The gestures of the editor keep layers within their limits on their + /// own. Call this after changing a layer from code to do the same, such as + /// after adding one once it is laid out: the bounds measure the layer from + /// its layout, so a layer that is not laid out yet is only scaled. + /// [editorBodySize] is the size of the main editor's body, which the + /// bounds are measured in. [laidOutScale] is the scale the layer was laid + /// out at, when its scale changed since; it defaults to its scale. + void applyLayerLimits( + Layer layer, { + required Size editorBodySize, + double? laidOutScale, + }) { + final interaction = layer.interaction; + if (interaction.minScale case final minScale? when layer.scale < minScale) { + layer.scale = minScale; + } + if (interaction.maxScale case final maxScale? when layer.scale > maxScale) { + layer.scale = maxScale; + } + _keepInsideBounds(layer, editorBodySize, laidOutScale: laidOutScale); + } + + /// Keeps [layer], which a drag moved, inside its [LayerInteraction.bounds] + /// and returns how far they moved it. + /// + /// The place the drag put it is kept, so the next step of the drag moves + /// on from there; with smart alignment it is kept already. A canvas helper + /// line the layer was snapped to is hidden once the bounds move it off. + Offset _keepMovedLayerInBounds(Layer layer, Size editorBodySize) { + if (layer.interaction.bounds == null) return Offset.zero; + if (!helperLineConfigs.enableSmartAlignment) { + _unconstrainedOffsets[layer.id] = layer.offset; + } + final shift = _keepInsideBounds(layer, editorBodySize); + if (shift.dx != 0) showVerticalHelperLine = false; + if (shift.dy != 0) showHorizontalHelperLine = false; + return shift; + } + + /// Keeps [layer], which a pinch or the scale button scaled or rotated, + /// inside its [LayerInteraction.bounds]. + /// + /// It is placed where the gesture found it first, so it moves back once it + /// is small enough again, and the rotation helper line moves with it. + void _keepTransformedLayerInBounds(Layer layer, Size editorBodySize) { + if (layer.interaction.bounds == null) return; + layer.offset = _unconstrainedOffsets[layer.id] ?? layer.offset; + final shift = _keepInsideBounds(layer, editorBodySize); + if (showRotationHelperLine) { + rotationHelperLineX += shift.dx; + rotationHelperLineY += shift.dy; + } + } + + /// Moves [layer] inside its [LayerInteraction.bounds], after shrinking it + /// to their size when it is larger, though not below its smallest scale, + /// and returns how far it moved. + /// + /// The layer covers the box it is drawn in at its scale, turned by its + /// rotation. That box is measured by [measureLayerSizes] for a running + /// gesture, or else from the layer's layout at [laidOutScale]. Does nothing + /// for a layer without bounds, or before it or the body is laid out. + Offset _keepInsideBounds( + Layer layer, + Size editorBodySize, { + double? laidOutScale, + }) { + final boundsOf = layer.interaction.bounds; + if (boundsOf == null || + editorBodySize.isEmpty || + !editorBodySize.isFinite) { + return Offset.zero; + } + var unitSize = _unitSizes[layer.id]; + if (unitSize == null) { + final size = layer.renderSize; + final scale = laidOutScale ?? layer.scale; + if (size == null || size.isEmpty || scale <= 0) return Offset.zero; + unitSize = size / scale; + } + final bounds = boundsOf(editorBodySize); + + final unitCover = _rotatedBoundingSize(unitSize, layer.rotation); + if (!bounds.isEmpty && unitCover.width > 0 && unitCover.height > 0) { + final fitScale = min( + bounds.width / unitCover.width, + bounds.height / unitCover.height, ); + if (layer.scale > fitScale) { + layer.scale = max(fitScale, _scaleLimits(configs, layer).$1); + } + } + + final size = unitSize * layer.scale; + final cover = _rotatedBoundingSize(size, layer.rotation); + final fractionalOffset = _getFractionalLayerOffset(layer); + final center = + editorBodySize.center(layer.offset) + + Offset( + size.width * (fractionalOffset.dx + 0.5), + size.height * (fractionalOffset.dy + 0.5), + ); + + double keepInside( + double center, + double halfLength, + double from, + double to, + ) { + // Centered along a side it does not fit. + if (to - from <= halfLength * 2) return (from + to) / 2; + return center.clamp(from + halfLength, to - halfLength); } + + final shift = Offset( + keepInside(center.dx, cover.width / 2, bounds.left, bounds.right) - + center.dx, + keepInside(center.dy, cover.height / 2, bounds.top, bounds.bottom) - + center.dy, + ); + if (shift != Offset.zero) layer.offset += shift; + return shift; } void _updateAlignmentGuides({ @@ -1472,7 +1655,10 @@ class LayerInteractionManager { }) { final showLayerAlign = helperLineConfigs.showLayerAlignLine; final customGuides = helperLineConfigs.customGuides; - if (!showLayerAlign && customGuides.isEmpty) return; + if (!showLayerAlign && customGuides.isEmpty) { + _keepMovedLayerInBounds(activeLayer, editorBodySize); + return; + } final snapThreshold = 3.0 / editorScaleFactor; final releaseThreshold = helperLineConfigs.releaseThreshold; @@ -1713,6 +1899,12 @@ class LayerInteractionManager { ); } + // The bounds win over a guide: one the layer cannot stay on is not + // shown, and it does not signal a hit on every step of the drag. + final boundsShift = _keepMovedLayerInBounds(activeLayer, editorBodySize); + if (boundsShift.dx != 0) isVerticalGuideVisible = false; + if (boundsShift.dy != 0) isHorizontalGuideVisible = false; + // Notify UI only if something changed final hasChanged = isHorizontalGuideVisible != wasHorizontalGuideVisible || diff --git a/lib/shared/services/import_export/constants/minified_keys.dart b/lib/shared/services/import_export/constants/minified_keys.dart index ce061964f..30b154f36 100644 --- a/lib/shared/services/import_export/constants/minified_keys.dart +++ b/lib/shared/services/import_export/constants/minified_keys.dart @@ -91,6 +91,8 @@ const Map kMinifiedLayerInteractionKeys = { 'enableRotate': 'r', 'enableSelection': 't', 'enableEdit': 'e', + 'minScale': 'n', + 'maxScale': 'x', }; /// A constant map containing minified paint-item keys for import/export diff --git a/lib/shared/services/import_export/import_state_history.dart b/lib/shared/services/import_export/import_state_history.dart index 1518a4d07..18e01ea36 100644 --- a/lib/shared/services/import_export/import_state_history.dart +++ b/lib/shared/services/import_export/import_state_history.dart @@ -130,11 +130,25 @@ class ImportStateHistory { historyItem[minifier.convertHistoryKey('layers')] ?? [], )) { String id = rawLayer['id']; + final lastLayerState = lastLayerStateHelper[id] ?? {}; Map convertedLayerMap = { - ...lastLayerStateHelper[id] ?? {}, + ...lastLayerState, ...rawLayer, }; + /// A step writes only the interaction settings that changed, so + /// they are merged over the previous ones rather than replacing + /// them, which would reset the others to their defaults. + final interactionKey = minifier.convertLayerKey('interaction'); + final lastInteraction = lastLayerState[interactionKey]; + final interaction = rawLayer[interactionKey]; + if (lastInteraction is Map && interaction is Map) { + convertedLayerMap[interactionKey] = { + ...Map.from(lastInteraction), + ...Map.from(interaction), + }; + } + if (version == ExportImportVersion.version_5_0_0) { historyCompatibilityLayerInteraction( layerMap: convertedLayerMap, diff --git a/pubspec.yaml b/pubspec.yaml index 9da3d2ffb..c39d1c1f5 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.22.0 +version: 14.23.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/core/models/layers/layer_interaction_test.dart b/test/core/models/layers/layer_interaction_test.dart new file mode 100644 index 000000000..694c5f59c --- /dev/null +++ b/test/core/models/layers/layer_interaction_test.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/core/models/layers/layer_interaction.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/services/import_export/utils/key_minifier.dart'; + +Rect _bounds(Size editorBodySize) => Offset.zero & editorBodySize; + +void main() { + group('LayerInteraction limits', () { + test('are not set by default', () { + final interaction = LayerInteraction(); + + expect(interaction.minScale, isNull); + expect(interaction.maxScale, isNull); + expect(interaction.bounds, isNull); + expect(LayerInteraction.fromDefaultValue(false).minScale, isNull); + }); + + test('toMap writes the scale limits only when they are set', () { + expect(LayerInteraction().toMap().keys, [ + 'enableMove', + 'enableScale', + 'enableRotate', + 'enableSelection', + 'enableEdit', + ]); + + final map = LayerInteraction( + minScale: 0.5, + maxScale: 2, + bounds: _bounds, + ).toMap(); + expect(map['minScale'], 0.5); + expect(map['maxScale'], 2); + expect(map.containsKey('bounds'), isFalse); + }); + + test('scale limits survive a JSON round trip, the bounds do not', () { + final interaction = LayerInteraction( + enableRotate: false, + minScale: 0.75, + maxScale: 1.5, + bounds: _bounds, + ); + + final json = jsonDecode(jsonEncode(interaction.toMap())); + final restored = LayerInteraction.fromMap( + Map.from(json as Map), + ); + + expect(restored.enableRotate, isFalse); + expect(restored.minScale, 0.75); + expect(restored.maxScale, 1.5); + expect(restored.bounds, isNull); + }); + + test('scale limits survive a minified round trip', () { + final minifier = EditorKeyMinifier(enableMinify: true); + final interaction = LayerInteraction(minScale: 0.75, maxScale: 1.5); + + final minified = interaction + .toMap(enableMinify: true) + .map( + (key, value) => + MapEntry(minifier.convertLayerInteractionKey(key), value), + ); + expect(minified['n'], 0.75); + expect(minified['x'], 1.5); + + final restored = LayerInteraction.fromMap( + minified, + keyConverter: minifier.convertLayerInteractionKey, + ); + expect(restored.minScale, 0.75); + expect(restored.maxScale, 1.5); + }); + + test('a map stored without the limits loads without them', () { + final restored = LayerInteraction.fromMap(const { + 'enableMove': true, + 'enableScale': false, + 'enableRotate': true, + 'enableSelection': true, + 'enableEdit': true, + }); + + expect(restored.enableScale, isFalse); + expect(restored.minScale, isNull); + expect(restored.maxScale, isNull); + + final minifier = EditorKeyMinifier(enableMinify: true); + final minified = LayerInteraction.fromMap(const { + 'm': true, + 's': false, + }, keyConverter: minifier.convertLayerInteractionKey); + expect(minified.enableScale, isFalse); + expect(minified.minScale, isNull); + expect(minified.maxScale, isNull); + }); + + test('toMapFromReference writes changed and removed scale limits', () { + final before = LayerInteraction(minScale: 0.5); + final after = LayerInteraction(maxScale: 2); + + expect(after.toMapFromReference(before), { + 'minScale': null, + 'maxScale': 2, + }); + expect(after.toMapFromReference(after.copyWith()), isEmpty); + }); + + test('copyWith keeps the limits and replaces given ones', () { + final interaction = LayerInteraction( + minScale: 0.5, + maxScale: 2, + bounds: _bounds, + ); + + final copy = interaction.copyWith(enableMove: false); + expect(copy.minScale, 0.5); + expect(copy.maxScale, 2); + expect(copy.bounds, same(_bounds)); + expect(copy.enableMove, isFalse); + + Rect other(Size size) => Rect.zero; + final replaced = interaction.copyWith(maxScale: 3, bounds: other); + expect(replaced.minScale, 0.5); + expect(replaced.maxScale, 3); + expect(replaced.bounds, same(other)); + }); + + test('limits take part in equality', () { + expect( + LayerInteraction(minScale: 0.5, bounds: _bounds), + LayerInteraction(minScale: 0.5, bounds: _bounds), + ); + expect( + LayerInteraction(minScale: 0.5), + isNot(LayerInteraction(minScale: 0.6)), + ); + expect( + LayerInteraction(maxScale: 2), + isNot(LayerInteraction(maxScale: 2, bounds: _bounds)), + ); + expect( + LayerInteraction(minScale: 0.5, bounds: _bounds).hashCode, + LayerInteraction(minScale: 0.5, bounds: _bounds).hashCode, + ); + }); + + test('asserts that maxScale is not below minScale', () { + expect( + () => LayerInteraction(minScale: 2, maxScale: 1), + throwsAssertionError, + ); + }); + }); + + group('Layer.toMapFromReference', () { + test('writes no interaction when only the bounds changed', () { + final layer = EmojiLayer( + id: 'a', + emoji: '🙂', + interaction: LayerInteraction(minScale: 0.5), + ); + final next = EmojiLayer( + id: 'a', + emoji: '🙂', + interaction: LayerInteraction(minScale: 0.5, bounds: _bounds), + ); + + expect(next.toMapFromReference(layer).containsKey('interaction'), false); + }); + + test('writes the interaction settings that changed', () { + final layer = EmojiLayer( + id: 'a', + emoji: '🙂', + interaction: LayerInteraction(minScale: 0.5), + ); + final next = EmojiLayer( + id: 'a', + emoji: '🙂', + interaction: LayerInteraction(minScale: 0.5, enableMove: false), + ); + + expect(next.toMapFromReference(layer)['interaction'], { + 'enableMove': false, + }); + }); + }); +} diff --git a/test/features/main_editor/layer_interaction_limits_test.dart b/test/features/main_editor/layer_interaction_limits_test.dart new file mode 100644 index 000000000..bb618b93f --- /dev/null +++ b/test/features/main_editor/layer_interaction_limits_test.dart @@ -0,0 +1,546 @@ +import 'dart:math'; + +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/layers/layer_interaction.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +import '../../mock/mock_image.dart'; + +/// A layer with [LayerInteraction.bounds] stays inside them however it is +/// dragged, pinched or turned, and [LayerInteraction.minScale] and +/// [LayerInteraction.maxScale] limit how far it is scaled. +void main() { + // How far a few floating point steps may put a layer past an edge. + const tolerance = 0.01; + + ProImageEditorConfigs configsWith({ + HelperLineConfigs helperLines = const HelperLineConfigs( + showVerticalLine: false, + showHorizontalLine: false, + showLayerAlignLine: false, + showRotateLine: false, + ), + EmojiEditorConfigs emojiEditor = const EmojiEditorConfigs(), + }) { + return ProImageEditorConfigs( + progressIndicatorConfigs: const ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: const ImageGenerationConfigs( + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + ), + helperLines: helperLines, + emojiEditor: emojiEditor, + ); + } + + Future pumpEditor( + WidgetTester tester, { + ProImageEditorConfigs? configs, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: configs ?? configsWith(), + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: (Uint8List bytes) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; + } + + /// A 300 x 240 area around the center of the body. + Rect centeredBounds(Size editorBodySize) => Rect.fromCenter( + center: editorBodySize.center(Offset.zero), + width: 300, + height: 240, + ); + + /// Adds a selected emoji, 96 px wide at its scale of 4, so both fingers of + /// a pinch land on it. + Future addSelectedLayer( + WidgetTester tester, + ProImageEditorState state, { + Offset offset = Offset.zero, + LayerInteraction? interaction, + }) async { + final layer = EmojiLayer( + emoji: '😀', + offset: offset, + scale: 4, + interaction: interaction, + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pump(); + state.layerInteractionManager + ..clearSelectedLayers() + ..addSelectedLayer(layer.id); + await tester.pump(); + return layer; + } + + Layer liveLayer(ProImageEditorState state, String id) => + state.activeLayers.singleWhere((layer) => layer.id == id); + + /// The area [layer] covers on the body as it is laid out: its box turned + /// by its rotation. + Rect coverOf(ProImageEditorState state, Layer layer) { + final size = layer.renderSize!; + final cosine = cos(layer.rotation).abs(); + final sine = sin(layer.rotation).abs(); + return Rect.fromCenter( + center: state.sizesManager.bodySize.center(layer.offset), + width: size.width * cosine + size.height * sine, + height: size.width * sine + size.height * cosine, + ); + } + + void expectInside(Rect cover, Rect bounds, {String? reason}) { + expect( + cover.left, + greaterThanOrEqualTo(bounds.left - tolerance), + reason: reason, + ); + expect( + cover.top, + greaterThanOrEqualTo(bounds.top - tolerance), + reason: reason, + ); + expect( + cover.right, + lessThanOrEqualTo(bounds.right + tolerance), + reason: reason, + ); + expect( + cover.bottom, + lessThanOrEqualTo(bounds.bottom + tolerance), + reason: reason, + ); + } + + Offset layerCenter(WidgetTester tester, Layer layer) => + tester.getCenter(find.byKey(layer.keyInternalSize)); + + group('bounds', () { + for (final smartAlignment in [false, true]) { + testWidgets('a dragged layer stops at the edge and follows the finger ' + 'again once it is back (smart alignment: $smartAlignment)', ( + tester, + ) async { + final state = await pumpEditor( + tester, + configs: configsWith( + helperLines: HelperLineConfigs( + showVerticalLine: false, + showHorizontalLine: false, + showLayerAlignLine: false, + showRotateLine: false, + enableSmartAlignment: smartAlignment, + ), + ), + ); + final layer = await addSelectedLayer( + tester, + state, + interaction: LayerInteraction(bounds: centeredBounds), + ); + final bounds = centeredBounds(state.sizesManager.bodySize); + final halfWidth = layer.renderSize!.width / 2; + final edge = bounds.right - state.sizesManager.bodySize.width / 2; + + var finger = layerCenter(tester, layer); + final gesture = await tester.startGesture(finger); + Future moveBy(double dx) async { + finger += Offset(dx, 0); + await gesture.moveTo(finger); + await tester.pump(); + } + + // Far enough for the gesture to start, still inside. + for (var i = 0; i < 4; i++) { + await moveBy(10); + } + final grab = liveLayer(state, layer.id).offset.dx - finger.dx; + + // Past the right edge. + for (var i = 0; i < 20; i++) { + await moveBy(10); + final live = liveLayer(state, layer.id); + expectInside(coverOf(state, live), bounds, reason: 'step $i'); + } + final stopped = liveLayer(state, layer.id).offset; + expect(stopped.dx + halfWidth, moreOrLessEquals(edge)); + expect(stopped.dy, 0); + + // The finger keeps going: the layer stays exactly where it stopped. + for (var i = 0; i < 5; i++) { + await moveBy(7); + expect(liveLayer(state, layer.id).offset, stopped); + } + + // Coming back, it waits until the finger is back where it stopped. + while (finger.dx + grab - 10 > stopped.dx) { + await moveBy(-10); + expect(liveLayer(state, layer.id).offset, stopped); + } + for (var i = 0; i < 3; i++) { + await moveBy(-10); + expect( + liveLayer(state, layer.id).offset.dx, + moreOrLessEquals(finger.dx + grab), + ); + } + + await gesture.up(); + await tester.pumpAndSettle(); + final released = liveLayer(state, layer.id); + expect(released.offset.dx, moreOrLessEquals(finger.dx + grab)); + expectInside(coverOf(state, released), bounds); + }); + } + + testWidgets('a layer without bounds is dragged as before', (tester) async { + final state = await pumpEditor(tester); + final layer = await addSelectedLayer(tester, state); + + var finger = layerCenter(tester, layer); + final gesture = await tester.startGesture(finger); + for (var i = 0; i < 4; i++) { + finger += const Offset(10, 0); + await gesture.moveTo(finger); + await tester.pump(); + } + final grab = liveLayer(state, layer.id).offset.dx - finger.dx; + for (var i = 0; i < 30; i++) { + finger += const Offset(10, 0); + await gesture.moveTo(finger); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + final bounds = centeredBounds(state.sizesManager.bodySize); + final live = liveLayer(state, layer.id); + expect(live.offset.dx, moreOrLessEquals(finger.dx + grab)); + expect(coverOf(state, live).right, greaterThan(bounds.right)); + }); + + testWidgets('a helper line the bounds move the layer off is hidden', ( + tester, + ) async { + final hits = []; + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: configsWith( + helperLines: const HelperLineConfigs( + showLayerAlignLine: false, + showRotateLine: false, + ), + ), + callbacks: ProImageEditorCallbacks( + mainEditorCallbacks: MainEditorCallbacks( + helperLines: HelperLinesCallbacks( + onHitVerticalLine: () => hits.add('vertical'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final state = key.currentState!; + + // Bounds right of the center line: the layer can never sit on it. + Rect rightHalf(Size size) => + Rect.fromLTRB(size.width / 2 + 10, 0, size.width, size.height); + final layer = await addSelectedLayer( + tester, + state, + offset: const Offset(150, 0), + interaction: LayerInteraction(bounds: rightHalf), + ); + + var finger = layerCenter(tester, layer); + final gesture = await tester.startGesture(finger); + for (var i = 0; i < 30; i++) { + finger -= const Offset(10, 0); + await gesture.moveTo(finger); + await tester.pump(); + expect( + state.layerInteractionManager.showVerticalHelperLine, + isFalse, + reason: 'step $i', + ); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(hits, isEmpty); + final live = liveLayer(state, layer.id); + expectInside( + coverOf(state, live), + rightHalf(state.sizesManager.bodySize), + ); + }); + + testWidgets('a layer pinched at an edge is pushed inwards, does not grow ' + 'past the bounds and moves back as it shrinks', (tester) async { + final state = await pumpEditor(tester); + const start = Offset(80, 0); + final layer = await addSelectedLayer( + tester, + state, + offset: start, + interaction: LayerInteraction(bounds: centeredBounds), + ); + final bounds = centeredBounds(state.sizesManager.bodySize); + + final center = layerCenter(tester, liveLayer(state, layer.id)); + final finger1 = await tester.startGesture( + center - const Offset(20, 0), + pointer: 1, + ); + final finger2 = await tester.startGesture( + center + const Offset(20, 0), + pointer: 2, + ); + await tester.pump(); + + Future spreadTo(double halfDistance) async { + await finger1.moveTo(center - Offset(halfDistance, 0)); + await finger2.moveTo(center + Offset(halfDistance, 0)); + await tester.pump(const Duration(milliseconds: 16)); + } + + for (var half = 25.0; half <= 120; half += 5) { + await spreadTo(half); + final live = liveLayer(state, layer.id); + expectInside(coverOf(state, live), bounds, reason: 'spread $half'); + } + final grown = liveLayer(state, layer.id); + expect(grown.offset.dx, lessThan(start.dx)); + // As tall as the bounds, and no taller. + expect(coverOf(state, grown).height, moreOrLessEquals(bounds.height)); + + for (var half = 115.0; half >= 20; half -= 5) { + await spreadTo(half); + final live = liveLayer(state, layer.id); + expectInside(coverOf(state, live), bounds, reason: 'shrink $half'); + } + expect(liveLayer(state, layer.id).offset, start); + + await finger1.up(); + await finger2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('a layer turned at an edge stays inside', (tester) async { + final state = await pumpEditor(tester); + final bounds = centeredBounds(state.sizesManager.bodySize); + // Its right edge near the right edge of the bounds. + final layer = await addSelectedLayer( + tester, + state, + offset: Offset(bounds.width / 2 - 50, 0), + interaction: LayerInteraction(bounds: centeredBounds), + ); + + final center = layerCenter(tester, layer); + final finger1 = await tester.startGesture( + center - const Offset(30, 0), + pointer: 1, + ); + final finger2 = await tester.startGesture( + center + const Offset(30, 0), + pointer: 2, + ); + await tester.pump(); + for (var i = 1; i <= 16; i++) { + final angle = (pi / 32) * i; + final arm = Offset(30 * cos(angle), 30 * sin(angle)); + await finger1.moveTo(center - arm); + await finger2.moveTo(center + arm); + await tester.pump(const Duration(milliseconds: 16)); + final live = liveLayer(state, layer.id); + expectInside(coverOf(state, live), bounds, reason: 'step $i'); + } + final turned = liveLayer(state, layer.id); + // The gesture starts a little late, so the layer turns less. + expect(turned.rotation, greaterThan(pi / 4)); + // Pushed inwards by the corners it turned out. + expect( + coverOf(state, turned).right, + moreOrLessEquals(bounds.right, epsilon: 0.5), + ); + + await finger1.up(); + await finger2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('applyLayerLimits moves a layer changed by code inside', ( + tester, + ) async { + final state = await pumpEditor(tester); + final layer = await addSelectedLayer( + tester, + state, + offset: const Offset(400, -300), + interaction: LayerInteraction(bounds: centeredBounds), + ); + final bounds = centeredBounds(state.sizesManager.bodySize); + expect(bounds.overlaps(coverOf(state, layer)), isFalse); + + state.layerInteractionManager.applyLayerLimits( + layer, + editorBodySize: state.sizesManager.bodySize, + ); + await tester.pump(); + + final cover = coverOf(state, layer); + expectInside(cover, bounds); + expect(cover.right, moreOrLessEquals(bounds.right)); + expect(cover.top, moreOrLessEquals(bounds.top)); + }); + }); + + group('scale limits', () { + Future pinchScale( + WidgetTester tester, + ProImageEditorState state, + Layer layer, { + required double toHalfDistance, + }) async { + final center = layerCenter(tester, liveLayer(state, layer.id)); + // Both fingers on the layer, 96 px wide at least. + const fromHalfDistance = 40.0; + final finger1 = await tester.startGesture( + center - const Offset(fromHalfDistance, 0), + pointer: 1, + ); + final finger2 = await tester.startGesture( + center + const Offset(fromHalfDistance, 0), + pointer: 2, + ); + await tester.pump(); + for (var i = 1; i <= 10; i++) { + final half = + fromHalfDistance + (toHalfDistance - fromHalfDistance) * i / 10; + await finger1.moveTo(center - Offset(half, 0)); + await finger2.moveTo(center + Offset(half, 0)); + await tester.pump(const Duration(milliseconds: 16)); + } + final scale = liveLayer(state, layer.id).scale; + await finger1.up(); + await finger2.up(); + await tester.pumpAndSettle(); + return scale; + } + + testWidgets('a pinch keeps the scale between minScale and maxScale', ( + tester, + ) async { + final state = await pumpEditor(tester); + final layer = await addSelectedLayer( + tester, + state, + interaction: LayerInteraction(minScale: 3, maxScale: 5), + ); + + expect(await pinchScale(tester, state, layer, toHalfDistance: 140), 5); + state.layerInteractionManager + ..clearSelectedLayers() + ..addSelectedLayer(layer.id); + await tester.pump(); + expect(await pinchScale(tester, state, layer, toHalfDistance: 8), 3); + }); + + testWidgets('the limits of a layer replace those of its type', ( + tester, + ) async { + final state = await pumpEditor( + tester, + configs: configsWith( + emojiEditor: const EmojiEditorConfigs(minScale: 3.5, maxScale: 4.5), + ), + ); + final limited = await addSelectedLayer( + tester, + state, + interaction: LayerInteraction(maxScale: 6), + ); + expect(await pinchScale(tester, state, limited, toHalfDistance: 140), 6); + + state.layerInteractionManager + ..clearSelectedLayers() + ..addSelectedLayer(limited.id); + await tester.pump(); + // The minimum of the type still applies. + expect(await pinchScale(tester, state, limited, toHalfDistance: 8), 3.5); + }); + + testWidgets('without limits on the layer, those of its type apply', ( + tester, + ) async { + final state = await pumpEditor( + tester, + configs: configsWith( + emojiEditor: const EmojiEditorConfigs(maxScale: 4.5), + ), + ); + final layer = await addSelectedLayer(tester, state); + expect(await pinchScale(tester, state, layer, toHalfDistance: 140), 4.5); + }); + + testWidgets('the bounds do not shrink a layer below its minScale', ( + tester, + ) async { + final state = await pumpEditor(tester); + // 96 px tall at its scale of 4, so 80 px tall bounds fit it at 3.33. + Rect flat(Size size) => Rect.fromCenter( + center: size.center(Offset.zero), + width: 300, + height: 80, + ); + final layer = await addSelectedLayer( + tester, + state, + interaction: LayerInteraction(minScale: 3.5, bounds: flat), + ); + + var finger = layerCenter(tester, layer); + final gesture = await tester.startGesture(finger); + for (var i = 0; i < 5; i++) { + finger += const Offset(0, 10); + await gesture.moveTo(finger); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + final live = liveLayer(state, layer.id); + expect(live.scale, 3.5); + // Too tall to fit, so it is centered on the bounds. + expect(live.offset.dy, moreOrLessEquals(0)); + }); + }); +} diff --git a/test/shared/services/import_export/layer_interaction_history_test.dart b/test/shared/services/import_export/layer_interaction_history_test.dart new file mode 100644 index 000000000..cb6ed93c7 --- /dev/null +++ b/test/shared/services/import_export/layer_interaction_history_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pro_image_editor/core/models/layers/layer_interaction.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/services/content_recorder/controllers/content_recorder_controller.dart'; +import 'package:pro_image_editor/shared/services/import_export/export_state_history.dart'; + +class _MockContentRecorderController extends Mock + implements ContentRecorderController {} + +class _MockBuildContext extends Mock implements BuildContext {} + +/// The limits of a layer's interaction have to survive an export and import +/// of the history, and a step that changes one interaction setting must not +/// reset the others. +void main() { + EmojiLayer layerWith(LayerInteraction interaction) => EmojiLayer( + id: 'poll', + emoji: '🙂', + offset: const Offset(10, 20), + interaction: interaction, + ); + + final steps = [ + LayerInteraction(enableRotate: false, minScale: 0.5, maxScale: 2), + LayerInteraction( + enableRotate: false, + enableMove: false, + minScale: 0.5, + maxScale: 2, + ), + LayerInteraction(enableRotate: false, enableMove: false, minScale: 0.5), + ]; + + Future exportAndImport({ + required bool enableMinify, + }) async { + final exporter = ExportStateHistory( + editorConfigs: const ProImageEditorConfigs(), + stateHistory: [ + EditorStateHistory(layers: [], transformConfigs: null), + for (final interaction in steps) + EditorStateHistory( + layers: [layerWith(interaction)], + transformConfigs: null, + ), + ], + imageInfos: const ImageInfos( + rawSize: Size(100, 200), + originalRenderedSize: Size(100, 200), + renderedSize: Size(100, 200), + cropRectSize: Size(100, 200), + pixelRatio: 1, + isRotated: false, + ), + editorPosition: steps.length, + contentRecorderCtrl: _MockContentRecorderController(), + context: _MockBuildContext(), + configs: ExportEditorConfigs( + enableMinify: enableMinify, + historySpan: ExportHistorySpan.all, + ), + ); + return ImportStateHistory.fromJson( + await exporter.toJson(), + configs: const ImportEditorConfigs(), + ); + } + + for (final enableMinify in [false, true]) { + test('every history step keeps its interaction ' + '(minified: $enableMinify)', () async { + final imported = await exportAndImport(enableMinify: enableMinify); + + final interactions = [ + for (final step in imported.stateHistory) + if (step.layers.isNotEmpty) step.layers.single.interaction, + ]; + expect(interactions, hasLength(steps.length)); + for (var i = 0; i < steps.length; i++) { + final interaction = interactions[i]; + expect(interaction.enableMove, steps[i].enableMove, reason: 'step $i'); + expect(interaction.enableRotate, isFalse, reason: 'step $i'); + expect(interaction.minScale, steps[i].minScale, reason: 'step $i'); + expect(interaction.maxScale, steps[i].maxScale, reason: 'step $i'); + expect(interaction.bounds, isNull, reason: 'step $i'); + } + }); + } + + test('a history stored without the limits loads without them', () { + final imported = ImportStateHistory.fromMap({ + 'version': '6.0.0', + 'position': 1, + 'imgSize': {'width': 100, 'height': 200}, + 'lastRenderedImgSize': {'width': 100, 'height': 200}, + 'references': { + 'poll': { + 'x': 10, + 'y': 20, + 'rotation': 0, + 'scale': 1, + 'flipX': false, + 'flipY': false, + 'interaction': { + 'enableMove': true, + 'enableScale': false, + 'enableRotate': false, + 'enableSelection': true, + 'enableEdit': true, + }, + 'type': 'emoji', + 'emoji': '🙂', + }, + }, + 'history': [ + {'layers': []}, + { + 'layers': [ + {'id': 'poll'}, + ], + }, + { + 'layers': [ + { + 'id': 'poll', + 'interaction': {'enableMove': false}, + }, + ], + }, + ], + }, configs: const ImportEditorConfigs()); + + final first = imported.stateHistory[1].layers.single.interaction; + expect(first.enableScale, isFalse); + expect(first.minScale, isNull); + expect(first.maxScale, isNull); + + // Only the changed setting was stored for the last step; the others + // carry over from the step before it. + final last = imported.stateHistory[2].layers.single.interaction; + expect(last.enableMove, isFalse); + expect(last.enableScale, isFalse); + expect(last.enableRotate, isFalse); + }); +} From c867f8a6fd1ac392c8d3762574aa9c2329cb4122 Mon Sep 17 00:00:00 2001 From: hm21 Date: Sat, 31 Oct 2026 09:42:46 +0100 Subject: [PATCH 2/2] fix(layers): keep the rotation helper line on a layer its bounds push The line was placed from where the previous step left the layer and then moved by the whole push of this step, so it drifted off a layer the bounds pushed. It now moves by how far the layer moved since the line was placed. Also note that resizing a text box ignores the bounds and shorten the changelog entry. --- CHANGELOG.md | 2 +- lib/core/models/layers/layer_interaction.dart | 5 +- .../services/layer_interaction_manager.dart | 9 ++- .../layer_interaction_limits_test.dart | 57 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b2049d8..c0dfec35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 14.23.0 -- **FEAT**(layers): Add `LayerInteraction.bounds` to keep a layer inside an area while it is moved, pinched or rotated, and `LayerInteraction.minScale` / `maxScale` to limit how far one layer scales; `LayerInteractionManager.applyLayerLimits` applies them to a layer changed by code. +- **FEAT**(layers): Add `LayerInteraction.bounds`, `minScale` and `maxScale` to keep one layer inside an area and within a scale range. - **FIX**(import-export): A history step that changes one interaction setting of a layer no longer resets its other settings on import. ## 14.22.0 diff --git a/lib/core/models/layers/layer_interaction.dart b/lib/core/models/layers/layer_interaction.dart index 0102136be..59b0f5cc9 100644 --- a/lib/core/models/layers/layer_interaction.dart +++ b/lib/core/models/layers/layer_interaction.dart @@ -159,8 +159,9 @@ class LayerInteraction { /// The scroll and keyboard shortcuts on a desktop keep it inside as well. /// /// The area applies only when the layer is moved: a layer that is added or - /// changed outside it by code stays where it is until then. Each layer of - /// a multi-selection stops at its own area. + /// changed outside it by code stays where it is until then, and so does a + /// text box widened past it by its resize handles. Each layer of a + /// multi-selection stops at its own area. /// /// A function cannot be stored, so an exported state does not hold the /// area: set it again on the layers after an import. Copies of the layer, diff --git a/lib/features/main_editor/services/layer_interaction_manager.dart b/lib/features/main_editor/services/layer_interaction_manager.dart index 4151b9570..64e613904 100644 --- a/lib/features/main_editor/services/layer_interaction_manager.dart +++ b/lib/features/main_editor/services/layer_interaction_manager.dart @@ -1567,11 +1567,14 @@ class LayerInteractionManager { /// is small enough again, and the rotation helper line moves with it. void _keepTransformedLayerInBounds(Layer layer, Size editorBodySize) { if (layer.interaction.bounds == null) return; + // Where the previous step left it, which the rotation helper line was + // placed at. + final shownOffset = layer.offset; layer.offset = _unconstrainedOffsets[layer.id] ?? layer.offset; - final shift = _keepInsideBounds(layer, editorBodySize); + _keepInsideBounds(layer, editorBodySize); if (showRotationHelperLine) { - rotationHelperLineX += shift.dx; - rotationHelperLineY += shift.dy; + rotationHelperLineX += layer.offset.dx - shownOffset.dx; + rotationHelperLineY += layer.offset.dy - shownOffset.dy; } } diff --git a/test/features/main_editor/layer_interaction_limits_test.dart b/test/features/main_editor/layer_interaction_limits_test.dart index bb618b93f..aaa8c3efd 100644 --- a/test/features/main_editor/layer_interaction_limits_test.dart +++ b/test/features/main_editor/layer_interaction_limits_test.dart @@ -397,6 +397,63 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('the rotation helper line stays on a layer the bounds push', ( + tester, + ) async { + final state = await pumpEditor( + tester, + configs: configsWith( + helperLines: const HelperLineConfigs( + showVerticalLine: false, + showHorizontalLine: false, + showLayerAlignLine: false, + ), + ), + ); + final manager = state.layerInteractionManager; + final bounds = centeredBounds(state.sizesManager.bodySize); + final layer = await addSelectedLayer( + tester, + state, + offset: Offset(bounds.width / 2 - 50, 0), + interaction: LayerInteraction(bounds: centeredBounds), + ); + + final center = layerCenter(tester, layer); + final finger1 = await tester.startGesture( + center - const Offset(30, 0), + pointer: 1, + ); + final finger2 = await tester.startGesture( + center + const Offset(30, 0), + pointer: 2, + ); + await tester.pump(); + var lineShown = false; + for (var i = 1; i <= 40; i++) { + final angle = (pi / 64) * i; + final arm = Offset(30 * cos(angle), 30 * sin(angle)); + await finger1.moveTo(center - arm); + await finger2.moveTo(center + arm); + await tester.pump(const Duration(milliseconds: 16)); + if (!manager.showRotationHelperLine) continue; + lineShown = true; + final live = liveLayer(state, layer.id); + final layerCenter = state.sizesManager.bodySize.center(live.offset); + expect(live.offset.dx, lessThan(bounds.width / 2 - 50)); + expect( + Offset(manager.rotationHelperLineX, manager.rotationHelperLineY), + offsetMoreOrLessEquals(layerCenter, epsilon: tolerance), + reason: 'step $i', + ); + } + expect(lineShown, isTrue); + + await finger1.up(); + await finger2.up(); + await tester.pumpAndSettle(); + }); + testWidgets('applyLayerLimits moves a layer changed by code inside', ( tester, ) async {