diff --git a/CHANGELOG.md b/CHANGELOG.md index 544e9a38..c0dfec35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 14.23.0 +- **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 - **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 0873243b..db4d3b4d 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 00000000..2b516d4e --- /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 6fbce1b6..7f38f68f 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 74cfcff6..59b0f5cc 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,57 @@ 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, 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, + /// 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 +177,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 +201,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 +220,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 +238,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 +247,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 +264,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 +276,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 +295,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 +308,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 721cf1a6..be9f697b 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 6b0c59b4..6778aee9 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 87744169..64e61390 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,190 @@ 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; + // 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; + _keepInsideBounds(layer, editorBodySize); + if (showRotationHelperLine) { + rotationHelperLineX += layer.offset.dx - shownOffset.dx; + rotationHelperLineY += layer.offset.dy - shownOffset.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 +1658,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 +1902,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 ce061964..30b154f3 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 1518a4d0..18e01ea3 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 9da3d2ff..c39d1c1f 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 00000000..694c5f59 --- /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 00000000..aaa8c3ef --- /dev/null +++ b/test/features/main_editor/layer_interaction_limits_test.dart @@ -0,0 +1,603 @@ +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('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 { + 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 00000000..cb6ed93c --- /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); + }); +}