From 6ccbdb89b44584e55ffadba850972c96df3fc862 Mon Sep 17 00:00:00 2001 From: Saif Addin Date: Tue, 20 Oct 2026 18:53:38 +0200 Subject: [PATCH 1/4] feat(main-editor): resizable text boxes --- .../layer_interaction_widgets.dart | 1 + .../layer_interaction_configs.dart | 12 + lib/core/models/layers/layer.dart | 1 + lib/core/models/layers/text_layer.dart | 21 ++ lib/core/models/layers/text_layer_box.dart | 280 ++++++++++++++++++ lib/features/main_editor/main_editor.dart | 13 + .../services/layer_copy_manager.dart | 1 + .../services/layer_interaction_manager.dart | 91 ++++++ .../services/main_editor_layers_service.dart | 37 ++- lib/features/text_editor/text_editor.dart | 48 ++- .../utils/rounded_background_painter.dart | 36 ++- .../rounded_background_text.dart | 137 +++++++-- .../constants/minified_keys.dart | 1 + .../layer_interaction_helper_widget.dart | 41 +++ .../text_layer_box_handles.dart | 132 +++++++++ lib/shared/widgets/layer/layer_widget.dart | 13 + .../layer/models/layer_item_interaction.dart | 20 ++ .../layer/widgets/layer_widget_text_item.dart | 4 + .../models/layers/text_layer_box_test.dart | 207 +++++++++++++ .../services/text_box_resize_test.dart | 131 ++++++++ .../text_box_resize_editor_test.dart | 186 ++++++++++++ .../rounded_background_text_box_test.dart | 198 +++++++++++++ .../text_editor/text_editor_box_test.dart | 129 ++++++++ .../layer/text_layer_box_handles_test.dart | 53 ++++ 24 files changed, 1763 insertions(+), 30 deletions(-) create mode 100644 lib/core/models/layers/text_layer_box.dart create mode 100644 lib/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart create mode 100644 test/core/models/layers/text_layer_box_test.dart create mode 100644 test/features/main_editor/services/text_box_resize_test.dart create mode 100644 test/features/main_editor/text_box_resize_editor_test.dart create mode 100644 test/features/text_editor/rounded_background_text_box_test.dart create mode 100644 test/features/text_editor/text_editor_box_test.dart create mode 100644 test/shared/widgets/layer/text_layer_box_handles_test.dart diff --git a/lib/core/models/custom_widgets/layer_interaction_widgets.dart b/lib/core/models/custom_widgets/layer_interaction_widgets.dart index 9849a2405..5640b4997 100644 --- a/lib/core/models/custom_widgets/layer_interaction_widgets.dart +++ b/lib/core/models/custom_widgets/layer_interaction_widgets.dart @@ -1,5 +1,6 @@ import 'utils/custom_widgets_typedef.dart'; +export '/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart'; export '/shared/widgets/layer/models/layer_item_interaction.dart'; /// A class that defines a layer of interaction for custom widgets, diff --git a/lib/core/models/editor_configs/layer_interaction_configs.dart b/lib/core/models/editor_configs/layer_interaction_configs.dart index 1b1e19cd0..d96cfc215 100644 --- a/lib/core/models/editor_configs/layer_interaction_configs.dart +++ b/lib/core/models/editor_configs/layer_interaction_configs.dart @@ -25,6 +25,7 @@ class LayerInteractionConfigs { this.enableLayerDragSelection = true, this.enableMobilePinchScale = true, this.enableMobilePinchRotate = true, + this.enableTextBoxResize = false, this.mouseButtonPrimaryAction = MouseButtonAction.selectOrSpaceMove, this.mouseButtonSecondaryAction = MouseButtonAction.pan, this.mouseButtonMiddleAction = MouseButtonAction.pan, @@ -63,6 +64,15 @@ class LayerInteractionConfigs { /// If `false`, pinch rotation is disabled. final bool enableMobilePinchRotate; + /// Whether the default selection overlay shows handles on the edges of a + /// text layer with a `TextLayer.box`, which resize the box without + /// scaling its text. + /// + /// A custom `LayerInteractionWidgets.overlayChildBuilder` can show its own + /// handles with `TextLayerBoxHandles` and + /// `LayerItemInteractions.textBoxResizeDown`, whatever this says. + final bool enableTextBoxResize; + /// Determines whether the video controls should be hidden when the user /// interacts with the editor. final bool hideVideoControlsOnInteraction; @@ -129,6 +139,7 @@ class LayerInteractionConfigs { bool? hideToolbarOnInteraction, bool? enableMobilePinchScale, bool? enableMobilePinchRotate, + bool? enableTextBoxResize, bool? hideVideoControlsOnInteraction, bool? keepSelectionOnInteraction, bool? enableKeyboardMultiSelection, @@ -151,6 +162,7 @@ class LayerInteractionConfigs { enableMobilePinchScale ?? this.enableMobilePinchScale, enableMobilePinchRotate: enableMobilePinchRotate ?? this.enableMobilePinchRotate, + enableTextBoxResize: enableTextBoxResize ?? this.enableTextBoxResize, hideVideoControlsOnInteraction: hideVideoControlsOnInteraction ?? this.hideVideoControlsOnInteraction, keepSelectionOnInteraction: diff --git a/lib/core/models/layers/layer.dart b/lib/core/models/layers/layer.dart index 4998cd39b..3859a3dd4 100644 --- a/lib/core/models/layers/layer.dart +++ b/lib/core/models/layers/layer.dart @@ -44,6 +44,7 @@ export 'layer_mask.dart'; export 'paint_layer.dart'; export 'text_highlight.dart'; export 'text_layer.dart'; +export 'text_layer_box.dart'; export 'widget_layer.dart'; /// Represents a layer with common properties for widgets. diff --git a/lib/core/models/layers/text_layer.dart b/lib/core/models/layers/text_layer.dart index d52691abc..207047ee3 100644 --- a/lib/core/models/layers/text_layer.dart +++ b/lib/core/models/layers/text_layer.dart @@ -45,6 +45,7 @@ class TextLayer extends Layer { this.align = TextAlign.left, this.fontScale = 1.0, this.maxTextWidth, + this.box, this.outlineWidth = 0, this.outlineColor = _defaultOutlineColor, List? highlights, @@ -170,6 +171,11 @@ class TextLayer extends Layer { text: map[keyConverter('text')] ?? '-', fontScale: fontScale, maxTextWidth: tryParseDouble(map[keyConverter('maxTextWidth')]), + box: map[keyConverter('box')] is Map + ? TextLayerBox.fromMap( + Map.from(map[keyConverter('box')] as Map), + ) + : null, outlineWidth: safeParseDouble(map[keyConverter('outlineWidth')]), outlineColor: outlineColor != null ? Color(safeParseInt(outlineColor)) @@ -253,6 +259,14 @@ class TextLayer extends Layer { /// null, the text will not have a width constraint. double? maxTextWidth; + /// A fixed box the text is laid out in, or `null` for a layer that grows + /// with its text. + /// + /// The text wraps at the box width instead of [maxTextWidth], and the + /// background fills the box. Set it to `null` to remove the box; + /// [copyWith] cannot clear it. + TextLayerBox? box; + /// A custom text style for the text. Be careful the editor allow not to /// import and export this style. /// @@ -406,6 +420,7 @@ class TextLayer extends Layer { 'type': 'text', if (maxTextWidth != null) 'maxTextWidth': maxTextWidth?.roundSmart(maxDecimalPlaces), + if (box != null) 'box': box!.toMap(), if (customSecondaryColor) 'customSecondaryColor': customSecondaryColor, if (highlights.isNotEmpty) 'highlights': highlights.map((h) => h.toMap()).toList(), @@ -479,6 +494,9 @@ class TextLayer extends Layer { 'decoration': textStyle?.decoration.toString(), if (paintLayer.maxTextWidth != maxTextWidth) 'maxTextWidth': maxTextWidth?.roundSmart(maxDecimalPlaces), + // `null` is written too, so a removed box does not come back from the + // previous step. + if (paintLayer.box != box) 'box': box?.toMap(), // An empty list is written on purpose: the import merges each entry // over the previous state, so a missing key would restore shadows the // user removed. @@ -522,6 +540,7 @@ class TextLayer extends Layer { double? rotation, double? scale, double? maxTextWidth, + TextLayerBox? box, double? outlineWidth, Color? outlineColor, List? highlights, @@ -558,6 +577,7 @@ class TextLayer extends Layer { align: align ?? this.align, fontScale: fontScale ?? this.fontScale, maxTextWidth: maxTextWidth ?? this.maxTextWidth, + box: box ?? this.box, outlineWidth: outlineWidth ?? this.outlineWidth, outlineColor: outlineColor ?? this.outlineColor, highlights: highlights ?? this.highlights, @@ -600,6 +620,7 @@ class TextLayer extends Layer { ..add(EnumProperty('align', align)) ..add(DoubleProperty('fontScale', fontScale)) ..add(DoubleProperty('maxTextWidth', maxTextWidth)) + ..add(DiagnosticsProperty('box', box)) ..add(DoubleProperty('outlineWidth', outlineWidth)) ..add(ColorProperty('outlineColor', outlineColor)) ..add(DiagnosticsProperty('textStyle', textStyle)) diff --git a/lib/core/models/layers/text_layer_box.dart b/lib/core/models/layers/text_layer_box.dart new file mode 100644 index 000000000..5cc4191fa --- /dev/null +++ b/lib/core/models/layers/text_layer_box.dart @@ -0,0 +1,280 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/painting.dart'; + +/// Where the text of a [TextLayerBox] sits when it is shorter than the box. +enum TextLayerBoxVerticalAlign { + /// The first line starts at the top of the box. + top, + + /// The text is centered in the box. + center, + + /// The last line ends at the bottom of the box. + bottom; + + /// The share of the free height above the text: `0` at the top, `1` at + /// the bottom. + double get factor => switch (this) { + TextLayerBoxVerticalAlign.top => 0, + TextLayerBoxVerticalAlign.center => 0.5, + TextLayerBoxVerticalAlign.bottom => 1, + }; +} + +/// What a [TextLayerBox] does with text taller than the box. +enum TextLayerBoxOverflow { + /// Lines below the box are cut off. + clip, + + /// The text gets smaller until it fits, down to + /// [TextLayerBox.minShrinkFactor] of its size; what still does not fit is + /// cut off. + shrink, +} + +/// An edge or corner of a [TextLayerBox] that resizes it when dragged. +enum TextLayerBoxHandle { + /// The left edge. + left(-1, 0), + + /// The top edge. + top(0, -1), + + /// The right edge. + right(1, 0), + + /// The bottom edge. + bottom(0, 1), + + /// The top left corner. + topLeft(-1, -1), + + /// The top right corner. + topRight(1, -1), + + /// The bottom left corner. + bottomLeft(-1, 1), + + /// The bottom right corner. + bottomRight(1, 1); + + const TextLayerBoxHandle(this.x, this.y); + + /// The side of the box the handle moves horizontally: `-1` left, `1` + /// right, `0` neither. + final int x; + + /// The side of the box the handle moves vertically: `-1` top, `1` bottom, + /// `0` neither. + final int y; + + /// The four edges, without the corners. + static const List edges = [left, top, right, bottom]; + + /// Where the handle sits on a box, as an [Alignment]. + Alignment get alignment => Alignment(x.toDouble(), y.toDouble()); +} + +/// A fixed box a `TextLayer` lays its text out in, like a text field on a +/// form, instead of growing with its text. +/// +/// The text wraps at the box width and never makes the layer taller. The +/// layer's background fills the whole box as one rectangle rather than +/// following the lines. +/// +/// [size] is measured like `TextLayer.maxTextWidth`: in the layer's own +/// units, before its `Layer.scale`, so the box grows and shrinks with the +/// layer and its text. +@immutable +class TextLayerBox { + /// Creates a [TextLayerBox]. + const TextLayerBox({ + required this.size, + this.verticalAlign = TextLayerBoxVerticalAlign.top, + this.overflow = TextLayerBoxOverflow.clip, + }); + + /// Creates a [TextLayerBox] from a map written by [toMap]. + /// + /// Values this version does not know fall back to the defaults, so a layer + /// saved by a newer version still loads. + factory TextLayerBox.fromMap(Map map) { + return TextLayerBox( + size: Size( + math.max(0, (map['width'] as num?)?.toDouble() ?? 0), + math.max(0, (map['height'] as num?)?.toDouble() ?? 0), + ), + verticalAlign: TextLayerBoxVerticalAlign.values.firstWhere( + (align) => align.name == map['verticalAlign'], + orElse: () => TextLayerBoxVerticalAlign.top, + ), + overflow: TextLayerBoxOverflow.values.firstWhere( + (overflow) => overflow.name == map['overflow'], + orElse: () => TextLayerBoxOverflow.clip, + ), + ); + } + + /// The smallest share of its size [TextLayerBoxOverflow.shrink] makes the + /// text. + static const double minShrinkFactor = 0.25; + + /// The width and height of the box, before the layer's scale. + final Size size; + + /// Where text shorter than the box sits. + final TextLayerBoxVerticalAlign verticalAlign; + + /// What happens to text taller than the box. + final TextLayerBoxOverflow overflow; + + /// A box that holds [text] the way a layer without a box shows it: laid + /// out in [style], wrapped at [maxTextWidth], padded as the layer pads its + /// text, and with room for an outline of [outlineWidth] and the shadows of + /// [style]. + /// + /// Measured in the layer's units, so [style] carries the font size before + /// the layer's scale (`TextEditorConfigs.initFontSize` times + /// `TextLayer.fontScale`). [slack] is added to the width and height, to + /// absorb rounding at other scales, which could otherwise wrap the last + /// word onto a new line. + static TextLayerBox fit({ + required String text, + required TextStyle style, + double? maxTextWidth, + TextAlign align = TextAlign.left, + double outlineWidth = 0, + double slack = 1, + TextLayerBoxVerticalAlign verticalAlign = TextLayerBoxVerticalAlign.top, + TextLayerBoxOverflow overflow = TextLayerBoxOverflow.shrink, + }) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textAlign: align, + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxTextWidth ?? double.infinity); + final lineHeight = painter.preferredLineHeight; + final textSize = painter.size; + painter.dispose(); + + // As `RoundedBackgroundText` pads a layer's text and makes room for its + // effects. + var effectX = outlineWidth; + var effectY = outlineWidth; + for (final shadow in style.shadows ?? const []) { + final blur = shadow.blurRadius > 0 + ? Shadow.convertRadiusToSigma(shadow.blurRadius) * 3 + : 0.0; + effectX = math.max(effectX, outlineWidth + shadow.offset.dx.abs() + blur); + effectY = math.max(effectY, outlineWidth + shadow.offset.dy.abs() + blur); + } + + return TextLayerBox( + size: Size( + textSize.width + (lineHeight * 0.3 + effectX) * 2 + slack, + textSize.height + (lineHeight * 0.1 + effectY) * 2 + slack, + ), + verticalAlign: verticalAlign, + overflow: overflow, + ); + } + + /// The smallest width and height [resize] leaves a box, before the + /// layer's scale. + static const Size minSize = Size(8, 8); + + /// The size and layer offset of a box of [size] whose [handle] was dragged + /// by [delta], with the opposite edges staying where they are. + /// + /// [delta] is measured on the canvas, the way the layer [offset] is, and + /// only its part along the dragged edges counts. The layer is drawn at + /// [scale], turned by [rotation] and mirrored by [flipX] and [flipY], with + /// [fractionalOffset] being the share of its size its offset is shifted by + /// (`Offset(-0.5, -0.5)` for a layer placed by its center). + static ({Size size, Offset offset}) resize({ + required Size size, + required Offset offset, + required TextLayerBoxHandle handle, + required Offset delta, + required double scale, + double rotation = 0, + bool flipX = false, + bool flipY = false, + Offset fractionalOffset = const Offset(-0.5, -0.5), + }) { + // The layer turns its content and then mirrors it. + Offset mirror(Offset v) => + Offset(flipX ? -v.dx : v.dx, flipY ? -v.dy : v.dy); + Offset turn(Offset v, double angle) { + final cos = math.cos(angle); + final sin = math.sin(angle); + return Offset(v.dx * cos - v.dy * sin, v.dx * sin + v.dy * cos); + } + + final local = turn(mirror(delta), -rotation); + final start = size * scale; + final minimum = minSize * scale; + final end = Size( + handle.x == 0 + ? start.width + : math.max(minimum.width, start.width + handle.x * local.dx), + handle.y == 0 + ? start.height + : math.max(minimum.height, start.height + handle.y * local.dy), + ); + + // The center follows half of the growth, towards the dragged edges. + final grown = end - start as Offset; + final centerShift = mirror( + turn(Offset(handle.x * grown.dx / 2, handle.y * grown.dy / 2), rotation), + ); + final anchor = fractionalOffset + const Offset(0.5, 0.5); + Offset anchorShift(Size s) => + Offset(anchor.dx * s.width, anchor.dy * s.height); + + return ( + size: end / scale, + offset: offset + anchorShift(start) + centerShift - anchorShift(end), + ); + } + + /// Converts the box to a map, readable by [TextLayerBox.fromMap]. + Map toMap() => { + 'width': size.width, + 'height': size.height, + 'verticalAlign': verticalAlign.name, + 'overflow': overflow.name, + }; + + /// Creates a copy with the given fields replaced. + TextLayerBox copyWith({ + Size? size, + TextLayerBoxVerticalAlign? verticalAlign, + TextLayerBoxOverflow? overflow, + }) { + return TextLayerBox( + size: size ?? this.size, + verticalAlign: verticalAlign ?? this.verticalAlign, + overflow: overflow ?? this.overflow, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TextLayerBox && + other.size == size && + other.verticalAlign == verticalAlign && + other.overflow == overflow; + } + + @override + int get hashCode => Object.hash(size, verticalAlign, overflow); + + @override + String toString() => + 'TextLayerBox(size: $size, verticalAlign: ${verticalAlign.name}, ' + 'overflow: ${overflow.name})'; +} diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index f33bfb894..b663dca3e 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -1551,6 +1551,19 @@ class ProImageEditorState extends State if (!hasSelectedLayers) return; + if (layerInteractionManager.isResizingTextBox) { + final layer = layerInteractionManager.calculateTextBoxResize( + details: details, + editorScaleFactor: interactiveViewer.currentState?.scaleFactor ?? 1.0, + layers: activeLayers, + ); + if (layer != null) { + mainEditorCallbacks?.handleUpdateLayer(layer); + layer.key.currentState?.setState(() {}); + } + return; + } + if (layerInteractionManager.rotateScaleLayerSizeHelper != null) { layerInteractionManager.calculateInteractiveButtonScaleRotate( configs: configs, diff --git a/lib/features/main_editor/services/layer_copy_manager.dart b/lib/features/main_editor/services/layer_copy_manager.dart index 1363c23dd..df5883640 100644 --- a/lib/features/main_editor/services/layer_copy_manager.dart +++ b/lib/features/main_editor/services/layer_copy_manager.dart @@ -144,6 +144,7 @@ class LayerCopyManager { flipY: layer.flipY, meta: layer.meta, maxTextWidth: layer.maxTextWidth, + box: layer.box, outlineWidth: layer.outlineWidth, outlineColor: layer.outlineColor, highlights: List.of(layer.highlights), diff --git a/lib/features/main_editor/services/layer_interaction_manager.dart b/lib/features/main_editor/services/layer_interaction_manager.dart index bcfbd29b2..0503406ef 100644 --- a/lib/features/main_editor/services/layer_interaction_manager.dart +++ b/lib/features/main_editor/services/layer_interaction_manager.dart @@ -367,6 +367,97 @@ class LayerInteractionManager { /// scaling operations. Size? rotateScaleLayerSizeHelper; + /// The edge or corner of the box of [activeInteractionLayer] a drag + /// resizes, or `null` while no box is resized. + /// + /// Set by [startTextBoxResize] when the user presses a box handle of the + /// selection overlay; the editor's scale gesture then resizes the box + /// instead of moving the layer (see [calculateTextBoxResize]). + TextLayerBoxHandle? textBoxResizeHandle; + + /// Whether a drag resizes the box of a text layer. + bool get isResizingTextBox => textBoxResizeHandle != null; + + String? _boxResizeLayerId; + TextLayerBox? _boxResizeStartBox; + Offset? _boxResizeStartOffset; + Offset? _boxResizeStartPointer; + + /// Lets the next drag resize the box of [layer] at [handle]. + /// + /// [pointerPosition] is where the handle was pressed, in global + /// coordinates; the drag is measured from there. Without it, the drag is + /// measured from the first update, which loses the distance the gesture + /// needed to start. + void startTextBoxResize( + TextLayer layer, + TextLayerBoxHandle handle, { + Offset? pointerPosition, + }) { + if (layer.box == null) return; + activeInteractionLayer = layer; + textBoxResizeHandle = handle; + _boxResizeLayerId = layer.id; + _boxResizeStartBox = layer.box; + _boxResizeStartOffset = layer.offset; + _boxResizeStartPointer = pointerPosition; + } + + /// Ends the box resize [startTextBoxResize] began. + void endTextBoxResize() { + textBoxResizeHandle = null; + _boxResizeLayerId = null; + _boxResizeStartBox = null; + _boxResizeStartOffset = null; + _boxResizeStartPointer = null; + } + + /// Resizes the box of the layer [startTextBoxResize] was called with to + /// follow the pointer of [details], keeping the edges opposite + /// [textBoxResizeHandle] in place, and returns that layer. + /// + /// The layer is looked up by id in [layers]: the history step the gesture + /// starts with copies the layers, and the copies are the ones shown. The + /// drag is measured on screen and divided by [editorScaleFactor], the + /// zoom of the canvas. + TextLayer? calculateTextBoxResize({ + required ScaleUpdateDetails details, + required double editorScaleFactor, + required List layers, + }) { + final layer = layers + .where((layer) => layer.id == _boxResizeLayerId) + .firstOrNull; + final handle = textBoxResizeHandle; + final startBox = _boxResizeStartBox; + final startOffset = _boxResizeStartOffset; + if (layer is! TextLayer || + handle == null || + startBox == null || + startOffset == null) { + return null; + } + + final pointer = details.focalPoint; + final startPointer = _boxResizeStartPointer ??= pointer; + + final resized = TextLayerBox.resize( + size: startBox.size, + offset: startOffset, + handle: handle, + delta: (pointer - startPointer) / editorScaleFactor, + scale: layer.scale, + rotation: layer.rotation, + flipX: layer.flipX, + flipY: layer.flipY, + fractionalOffset: _getFractionalLayerOffset(layer), + ); + layer + ..box = startBox.copyWith(size: resized.size) + ..offset = resized.offset; + return layer; + } + /// Represents the layer being interacted with during a /// scale or rotate gesture. /// diff --git a/lib/features/main_editor/services/main_editor_layers_service.dart b/lib/features/main_editor/services/main_editor_layers_service.dart index 24692ad3f..6fe66a646 100644 --- a/lib/features/main_editor/services/main_editor_layers_service.dart +++ b/lib/features/main_editor/services/main_editor_layers_service.dart @@ -98,6 +98,11 @@ class MainEditorLayersService { Set _temporarySelectedIds = {}; bool _isScaleInteractionActive = false; + + /// Whether a handle of the selection overlay, such as the rotate/scale + /// button or a box handle, is pressed. The layer under it gets the press + /// as well, but must not treat it as its own tap or long press. + bool get isScaleInteractionActive => _isScaleInteractionActive; bool _helperIsPointerDownSelected = false; bool _helperEnforceMultiSelect = false; bool _helperMouseDownMultiSelect = false; @@ -292,6 +297,31 @@ class MainEditorLayersService { callbacks.mainEditorCallbacks?.handleUpdateUI(); } + /// Called when the user presses [handle] of the box of [layer] at the + /// global [pointerPosition] to resize it. + void handleTextBoxResizeDown( + TextLayer layer, + TextLayerBoxHandle handle, + Offset pointerPosition, + ) { + _isScaleInteractionActive = true; + layerInteraction.startTextBoxResize( + layer, + handle, + pointerPosition: pointerPosition, + ); + onCheckInteractiveViewer(); + } + + /// Called when the user lets go of a box handle. + void handleTextBoxResizeUp() { + _isScaleInteractionActive = false; + layerInteraction.endTextBoxResize(); + _validateClearLayer(); + onCheckInteractiveViewer(); + callbacks.mainEditorCallbacks?.handleUpdateUI(); + } + /// Removes the given layer from the canvas and updates the UI. void handleRemoveLayer(Layer layer) { state.removeLayer(layer); @@ -368,9 +398,14 @@ class MainEditorLayersService { bool areLayersSelectable = false, bool isSelected = false, }) { + // A held handle is not a long press on the layer, even when the drag + // leaves the layer's offset alone, as resizing a box from its far edges + // does. if (!areLayersSelectable || !_layerInteractionConfigs.enableLongPressMultiSelection || - mouseService.validatePanAction()) { + mouseService.validatePanAction() || + _isScaleInteractionActive || + state.isLayerBeingTransformed) { return; } diff --git a/lib/features/text_editor/text_editor.dart b/lib/features/text_editor/text_editor.dart index b67f32dbb..2f1c2ac05 100644 --- a/lib/features/text_editor/text_editor.dart +++ b/lib/features/text_editor/text_editor.dart @@ -102,6 +102,42 @@ class TextEditorState extends State Color get outlineColor => _outlineColor; Color _outlineColor = const Color(0xFF000000); + /// Whether the text goes into a fixed box (see [TextLayer.box]) when + /// editing is done. + bool get boxEnabled => _boxEnabled; + late bool _boxEnabled = widget.layer?.box != null; + + /// Puts the text into a box, or takes it out of one, when editing is done. + /// + /// A layer that already has a box keeps its size. Otherwise the box is + /// sized to hold the text as it shows without one, so turning the box on + /// changes nothing until it is resized. + void setBoxEnabled(bool enabled) { + setState(() => _boxEnabled = enabled); + } + + /// The box the edited layer gets for [text], wrapped at [maxTextWidth]. + TextLayerBox? _resultBox(String text, double? maxTextWidth) { + if (!_boxEnabled) return null; + final original = widget.layer; + if (original?.box case final box?) return box; + final scale = original?.scale ?? 1; + return TextLayerBox.fit( + text: text, + style: selectedTextStyle.copyWith( + fontSize: _textFontSize, + shadows: selectedTextStyle.shadows + ?.map((shadow) => shadow.scale(_fontScale)) + .toList(), + ), + maxTextWidth: maxTextWidth, + align: align, + outlineWidth: _outlineWidth * _fontScale, + // About two pixels on screen, whatever the layer's scale. + slack: 2 / (scale <= 0 ? 1 : scale), + ); + } + /// Sets the outline drawn around each glyph; a [width] of `0` removes it. /// /// Omitted values keep their current setting. @@ -345,6 +381,11 @@ class TextEditorState extends State if (textCtrl.text.trim().isNotEmpty || widget.layer != null) { final original = widget.layer; final text = textCtrl.text.trim(); + final maxTextWidth = + (textEditorConfigs.enableAutoWrapOnLayer || + textEditorConfigs.enableImageBoundaryTextWrap) + ? _maxTextWidth + : null; TextLayer layer = TextLayer( text: text, background: _backgroundColor, @@ -356,11 +397,7 @@ class TextEditorState extends State outlineWidth: _outlineWidth, outlineColor: _outlineColor, customSecondaryColor: _secondaryColor != null, - maxTextWidth: - (textEditorConfigs.enableAutoWrapOnLayer || - textEditorConfigs.enableImageBoundaryTextWrap) - ? _maxTextWidth - : null, + maxTextWidth: maxTextWidth, // The editor changes how the text looks, not when it shows. startTime: original?.startTime, endTime: original?.endTime, @@ -377,6 +414,7 @@ class TextEditorState extends State // A mask is measured in the layer's own box, so it fits the edited // text as well. mask: original?.mask, + box: _resultBox(text, maxTextWidth ?? original?.maxTextWidth), // Highlights point into the text, so they only fit the text they // were made for. highlights: original?.text == text ? original?.highlights : null, diff --git a/lib/features/text_editor/utils/rounded_background_painter.dart b/lib/features/text_editor/utils/rounded_background_painter.dart index e45b32ed7..4cac09bf6 100644 --- a/lib/features/text_editor/utils/rounded_background_painter.dart +++ b/lib/features/text_editor/utils/rounded_background_painter.dart @@ -33,6 +33,7 @@ class RoundedBackgroundTextPainter extends CustomPainter { this.silhouettePainters = const [], this.silhouetteShadows = const [], this.visibleLength, + this.boxSize, }); /// Callback function triggered with the result of a hit test on the text @@ -91,6 +92,13 @@ class RoundedBackgroundTextPainter extends CustomPainter { /// layer can be picked before its text has appeared. final int? visibleLength; + /// The size of the fixed box the text is laid out in, or `null` when the + /// background follows the lines. + /// + /// The background then fills the box as one rectangle, the text is clipped + /// to the box, and a hit anywhere in the box counts. + final Size? boxSize; + /// The background behind the drawn text, or behind all of it when /// [revealedOnly] is `false` (see [visibleLength]). Path _buildBackgroundPath({bool revealedOnly = true}) { @@ -506,6 +514,20 @@ class RoundedBackgroundTextPainter extends CustomPainter { void paint(Canvas canvas, Size size) { final painter = Paint()..color = backgroundColor; + if (boxSize case final boxSize?) { + final box = Offset.zero & boxSize; + canvas + ..save() + ..clipRect(box) + ..drawRect(box, painter) + ..translate(hitBoxCorrectionOffset.dx, hitBoxCorrectionOffset.dy); + _paintSilhouetteShadows(canvas); + outlinePainter?.paint(canvas, Offset.zero); + this.painter.paint(canvas, Offset.zero); + canvas.restore(); + return; + } + final background = _buildBackgroundPath(); // Only the background of the whole text serves hit testing. if (visibleLength == null) _cachedPath = background; @@ -558,6 +580,11 @@ class RoundedBackgroundTextPainter extends CustomPainter { @override bool? hitTest(Offset position) { + if (boxSize case final boxSize?) { + final hasHit = (Offset.zero & boxSize).contains(position); + onHitTestResult?.call(hasHit); + return hasHit; + } final path = _cachedPath ??= _buildBackgroundPath(revealedOnly: false); bool hasHit = path.contains(position - hitBoxCorrectionOffset); @@ -587,7 +614,8 @@ class RoundedBackgroundTextPainter extends CustomPainter { oldDelegate.hitBoxCorrectionOffset != hitBoxCorrectionOffset || oldDelegate.textDirection != textDirection || oldDelegate.outerRadius != outerRadius || - oldDelegate.visibleLength != visibleLength; + oldDelegate.visibleLength != visibleLength || + oldDelegate.boxSize != boxSize; if (changed) _cachedPath = null; // invalidate cache @@ -613,7 +641,8 @@ class RoundedBackgroundTextPainter extends CustomPainter { other.outlineColor == outlineColor && listEquals(other.silhouettePainters, silhouettePainters) && listEquals(other.silhouetteShadows, silhouetteShadows) && - other.visibleLength == visibleLength; + other.visibleLength == visibleLength && + other.boxSize == boxSize; } @override @@ -632,6 +661,7 @@ class RoundedBackgroundTextPainter extends CustomPainter { outlineColor.hashCode ^ Object.hashAll(silhouettePainters) ^ Object.hashAll(silhouetteShadows) ^ - visibleLength.hashCode; + visibleLength.hashCode ^ + boxSize.hashCode; } } diff --git a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart index 2f30ce432..11c4c78ec 100644 --- a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart +++ b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; +import '/core/models/layers/text_layer_box.dart'; import '../../utils/rounded_background_painter.dart'; /// Creates a [RoundedBackgroundText] widget with plain text and optional @@ -36,6 +37,7 @@ class RoundedBackgroundText extends StatelessWidget { this.outlineColor = const Color(0xFF000000), this.reserveEffectSpace = false, this.visibleLength, + this.box, }) : text = TextSpan(text: text, style: style); /// Creates a [RoundedBackgroundText] widget with rich text using @@ -59,6 +61,7 @@ class RoundedBackgroundText extends StatelessWidget { this.outlineColor = const Color(0xFF000000), this.reserveEffectSpace = false, this.visibleLength, + this.box, }); /// A flag to enable or disable hitBox correction for the text. @@ -133,6 +136,16 @@ class RoundedBackgroundText extends StatelessWidget { /// types itself out shows that way, its background growing with it. final int? visibleLength; + /// A fixed box to lay the text out in, its size in logical pixels, or + /// `null` to size the widget to its text. + /// + /// The widget then takes the size of the box, the text wraps at its width + /// (less the padding and effect space) instead of [maxTextWidth], + /// [maxWidth] and [softMaxWidth], and [backgroundColor] fills the whole + /// box as one rectangle, which is also what hit testing reads. Text taller + /// than the box is clipped or shrunk, as [TextLayerBox.overflow] says. + final TextLayerBox? box; + bool get _hasOutline => outlineWidth > 0 && outlineColor.a > 0; @override @@ -198,6 +211,8 @@ class RoundedBackgroundText extends StatelessWidget { final shadows = style.shadows ?? const []; final hasOutline = _hasOutline; + var textScaler = TextScaler.noScaling; + TextPainter createPainter(TextStyle Function(TextStyle style)? restyle) { var content = restyle == null ? text : _restyleSpan(text, restyle); // Hidden last, so no pass, the outline's included, paints that part. @@ -214,6 +229,7 @@ class RoundedBackgroundText extends StatelessWidget { textAlign: align, textWidthBasis: defaultTextStyle.textWidthBasis, textHeightBehavior: defaultTextStyle.textHeightBehavior, + textScaler: textScaler, ); } @@ -233,6 +249,20 @@ class RoundedBackgroundText extends StatelessWidget { ..strokeCap = StrokeCap.round ..color = color; + final effectSpace = reserveEffectSpace + ? _effectSpace( + outlineWidth: hasOutline ? outlineWidth : 0, + shadows: shadows, + ) + : Offset.zero; + + final box = this.box; + if (box != null && box.overflow == TextLayerBoxOverflow.shrink) { + textScaler = TextScaler.linear( + _shrinkFactor(createPainter(null), box.size, effectSpace), + ); + } + // With an outline, the shadows are cast by the outlined glyphs, which the // painter draws from opaque silhouettes; the passes themselves draw none. final painter = createPainter( @@ -254,12 +284,45 @@ class RoundedBackgroundText extends StatelessWidget { double horizontalSpace = enableHitBoxCorrection ? height * 0.3 : 0; double verticalSpace = enableHitBoxCorrection ? height * 0.1 : 0; - final effectSpace = reserveEffectSpace - ? _effectSpace( - outlineWidth: hasOutline ? outlineWidth : 0, - shadows: shadows, - ) - : Offset.zero; + RoundedBackgroundTextPainter createBackgroundPainter( + Offset textOffset, { + Size? boxSize, + }) { + return RoundedBackgroundTextPainter( + backgroundColor: backgroundColor ?? Colors.transparent, + painter: painter, + outlinePainter: outlinePainter, + outlineWidth: hasOutline ? outlineWidth : 0, + outlineColor: outlineColor, + silhouettePainters: silhouettePainters, + silhouetteShadows: hasOutlineShadows ? shadows : const [], + onHitTestResult: onHitTestResult, + textAlign: align, + cursorWidth: cursorWidth, + textDirection: Directionality.of(context), + hitBoxCorrectionOffset: textOffset, + visibleLength: visibleLength, + boxSize: boxSize, + ); + } + + if (box != null) { + final inset = Offset(horizontalSpace, verticalSpace) + effectSpace; + final room = _boxRoom(box.size, inset); + final textPainters = [painter, ?outlinePainter, ...silhouettePainters]; + for (final textPainter in textPainters) { + textPainter.layout(minWidth: room.width, maxWidth: room.width); + } + final freeHeight = max(0.0, room.height - painter.height); + final textOffset = inset.translate( + 0, + freeHeight * box.verticalAlign.factor, + ); + return ( + painter: createBackgroundPainter(textOffset, boxSize: box.size), + size: constraints.constrain(box.size), + ); + } final reservedWidth = (horizontalSpace + effectSpace.dx) * 2; var textMaxWidth = min(maxTextWidth, max(0.0, maxWidth - reservedWidth)); @@ -278,21 +341,8 @@ class RoundedBackgroundText extends StatelessWidget { } return ( - painter: RoundedBackgroundTextPainter( - backgroundColor: backgroundColor ?? Colors.transparent, - painter: painter, - outlinePainter: outlinePainter, - outlineWidth: hasOutline ? outlineWidth : 0, - outlineColor: outlineColor, - silhouettePainters: silhouettePainters, - silhouetteShadows: hasOutlineShadows ? shadows : const [], - onHitTestResult: onHitTestResult, - textAlign: align, - cursorWidth: cursorWidth, - textDirection: Directionality.of(context), - hitBoxCorrectionOffset: - Offset(horizontalSpace, verticalSpace) + effectSpace, - visibleLength: visibleLength, + painter: createBackgroundPainter( + Offset(horizontalSpace, verticalSpace) + effectSpace, ), size: Size( painter.width.clamp(0, constraints.maxWidth) + @@ -303,6 +353,51 @@ class RoundedBackgroundText extends StatelessWidget { ); } + /// The room a box of [size] leaves for its text inside [inset] on every + /// side. + static Size _boxRoom(Size size, Offset inset) { + return Size( + max(0.0, size.width - inset.dx * 2), + max(0.0, size.height - inset.dy * 2), + ); + } + + /// The largest text scale, down to [TextLayerBox.minShrinkFactor], at + /// which the text of [probe] fits a box of [boxSize] whose text keeps + /// [effectSpace] to its edges. Disposes [probe]. + double _shrinkFactor(TextPainter probe, Size boxSize, Offset effectSpace) { + bool fits(double factor) { + probe.textScaler = TextScaler.linear(factor); + // The padding follows the line height, which shrinks with the text. + final lineHeight = probe.preferredLineHeight; + final inset = enableHitBoxCorrection + ? Offset(lineHeight * 0.3, lineHeight * 0.1) + effectSpace + : effectSpace; + final room = _boxRoom(boxSize, inset); + probe.layout(maxWidth: room.width); + return probe.height <= room.height; + } + + try { + if (fits(1)) return 1; + var low = TextLayerBox.minShrinkFactor; + if (!fits(low)) return low; + var high = 1.0; + // Ten halvings settle the size within a thousandth of the range. + for (var i = 0; i < 10; i++) { + final middle = (low + high) / 2; + if (fits(middle)) { + low = middle; + } else { + high = middle; + } + } + return low; + } finally { + probe.dispose(); + } + } + /// How far the outline and the shadows paint beyond the glyphs on each /// axis. static Offset _effectSpace({ diff --git a/lib/shared/services/import_export/constants/minified_keys.dart b/lib/shared/services/import_export/constants/minified_keys.dart index b788deb22..9bdda1961 100644 --- a/lib/shared/services/import_export/constants/minified_keys.dart +++ b/lib/shared/services/import_export/constants/minified_keys.dart @@ -60,6 +60,7 @@ const Map kMinifiedLayerKeys = { 'meta': 'm', 'boxConstraints': 'bx', 'maxTextWidth': 'mt', + 'box': 'tb', 'shadows': 'sh', 'outlineWidth': 'ow', 'outlineColor': 'oc', diff --git a/lib/shared/widgets/layer/interaction_helper/layer_interaction_helper_widget.dart b/lib/shared/widgets/layer/interaction_helper/layer_interaction_helper_widget.dart index 69d69a46d..a26701f9e 100644 --- a/lib/shared/widgets/layer/interaction_helper/layer_interaction_helper_widget.dart +++ b/lib/shared/widgets/layer/interaction_helper/layer_interaction_helper_widget.dart @@ -1,5 +1,6 @@ // Flutter imports: import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; @@ -16,6 +17,7 @@ import '/shared/widgets/reactive_widgets/reactive_custom_widget.dart'; import '../models/layer_item_interaction.dart'; import 'layer_interaction_border_painter.dart'; import 'layer_interaction_button.dart'; +import 'text_layer_box_handles.dart'; /// A stateful widget that provides interactive controls for manipulating /// layers in an image editor. @@ -60,6 +62,8 @@ class LayerInteractionHelperWidget extends StatefulWidget this.onDuplicate, this.onScaleRotateDown, this.onScaleRotateUp, + this.onTextBoxResizeDown, + this.onTextBoxResizeUp, this.onGroupLayers, this.onUngroupLayers, this.selected = false, @@ -118,6 +122,14 @@ class LayerInteractionHelperWidget extends StatefulWidget /// or rotating, finalizing the interaction. final Function(PointerUpEvent)? onScaleRotateUp; + /// Called when a handle of a text layer's box is pressed, to resize the + /// box with the drag that follows. + final void Function(PointerDownEvent event, TextLayerBoxHandle handle)? + onTextBoxResizeDown; + + /// Called when the pressed box handle is let go. + final void Function(PointerUpEvent event)? onTextBoxResizeUp; + /// Callback for grouping layers. /// /// This callback is triggered when the user wants to group the current @@ -221,6 +233,9 @@ class _LayerInteractionHelperWidgetState scaleRotateUp: _handleScaleRotateUp, group: widget.onGroupLayers ?? () {}, ungroup: widget.onUngroupLayers ?? () {}, + textBoxResizeDown: (event, handle) => + widget.onTextBoxResizeDown?.call(event, handle), + textBoxResizeUp: (event) => widget.onTextBoxResizeUp?.call(event), ); } @@ -366,6 +381,11 @@ class _LayerInteractionHelperWidgetState List _buildDefaultInteractions() { return [ + if (layerInteraction.enableTextBoxResize) + (rebuildStream, layer, interactions) => ReactiveWidget( + stream: rebuildStream, + builder: (_) => _buildTextBoxHandles(interactions), + ), if (_isLayerEditable()) (rebuildStream, layer, interactions) => ReactiveWidget( stream: rebuildStream, @@ -382,6 +402,27 @@ class _LayerInteractionHelperWidgetState ]; } + Widget _buildTextBoxHandles(LayerItemInteractions interactions) { + final layer = _layer; + if (layer is! TextLayer || layer.box == null) { + return const SizedBox.shrink(); + } + final style = layerInteraction.style; + // The corners hold the buttons, so only the edges get handles. The + // overlay is padded around the layer, which leaves room for them. + return Positioned.fill( + child: TextLayerBoxHandles( + handles: TextLayerBoxHandle.edges, + boxInset: style.overlayPadding, + size: math.min(12, style.overlayPadding.horizontal), + rotation: layer.rotation, + color: style.borderColor, + onResizeDown: interactions.textBoxResizeDown, + onResizeUp: interactions.textBoxResizeUp, + ), + ); + } + Widget _buildRotateScaleButton(LayerItemInteractions interactions) { return layerInteraction.widgets.rotateScaleButton?.call( _rebuildStream.stream, diff --git a/lib/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart b/lib/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart new file mode 100644 index 000000000..605ae7292 --- /dev/null +++ b/lib/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:material_ui/material_ui.dart'; + +import '/core/models/layers/text_layer_box.dart'; + +/// Handles on the edges, and optionally the corners, of a text layer's +/// [TextLayerBox] that resize the box when dragged. +/// +/// Place it over the layer in the selection overlay, in the layer's own +/// space (inside the transform that places the layer), filling the area +/// whose edges lie [boxInset] outside the box. A pressed handle calls +/// [onResizeDown]; the editor's own drag gesture then resizes the box, and +/// [onResizeUp] ends the resize. Wire them to +/// `LayerItemInteractions.textBoxResizeDown` and `textBoxResizeUp`. +/// +/// A handle reaches [size] / 2 beyond the box edge, so [boxInset] should be +/// at least that, or the outer half of each handle cannot be hit. +class TextLayerBoxHandles extends StatelessWidget { + /// Creates [TextLayerBoxHandles]. + const TextLayerBoxHandles({ + super.key, + required this.onResizeDown, + required this.onResizeUp, + this.handles = TextLayerBoxHandle.values, + this.boxInset = EdgeInsets.zero, + this.size = 12, + this.color = Colors.blue, + this.fillColor = Colors.white, + this.rotation = 0, + }); + + /// Called when a handle is pressed. + final void Function(PointerDownEvent event, TextLayerBoxHandle handle) + onResizeDown; + + /// Called when the pressed handle is let go, or the press is canceled. + final void Function(PointerUpEvent event) onResizeUp; + + /// The handles to show. + final List handles; + + /// How far inside this widget the box edges lie. + final EdgeInsets boxInset; + + /// The side of the square each handle can be grabbed in. Edge handles are + /// drawn as a bar along the edge, corners as a square. + final double size; + + /// The color of the handle outlines. + final Color color; + + /// The color the handles are filled with. + final Color fillColor; + + /// The rotation of the layer, which turns the resize cursors with it. + final double rotation; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final box = boxInset.deflateRect(Offset.zero & constraints.biggest); + return Stack( + clipBehavior: Clip.none, + children: [ + for (final handle in handles) + Positioned( + left: box.center.dx + handle.x * box.width / 2 - size / 2, + top: box.center.dy + handle.y * box.height / 2 - size / 2, + width: size, + height: size, + child: _buildHandle(handle), + ), + ], + ); + }, + ); + } + + Widget _buildHandle(TextLayerBoxHandle handle) { + final isCorner = handle.x != 0 && handle.y != 0; + final thickness = size / 2; + // Like the layer buttons, a handle hears the press but lets it through, + // so the editor's own drag gesture still starts and does the resizing. + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) => onResizeDown(event, handle), + onPointerUp: onResizeUp, + onPointerCancel: (event) => onResizeUp( + PointerUpEvent( + pointer: event.pointer, + position: event.position, + timeStamp: event.timeStamp, + ), + ), + child: MouseRegion( + cursor: _cursorFor(handle), + hitTestBehavior: HitTestBehavior.translucent, + child: IgnorePointer( + child: Center( + child: Container( + width: isCorner || handle.x != 0 ? thickness : size, + height: isCorner || handle.y != 0 ? thickness : size, + decoration: BoxDecoration( + color: fillColor, + border: Border.all(color: color, width: size / 12), + borderRadius: BorderRadius.circular(isCorner ? 1 : thickness), + ), + ), + ), + ), + ), + ); + } + + /// The resize cursor closest to the direction [handle] moves in on + /// screen. + MouseCursor _cursorFor(TextLayerBoxHandle handle) { + final angle = math.atan2(handle.y.toDouble(), handle.x.toDouble()); + // Cursors repeat every half turn; split that into four directions. + final turned = (angle + rotation) % math.pi; + final step = ((turned < 0 ? turned + math.pi : turned) / (math.pi / 4)) + .round(); + return switch (step % 4) { + 0 => SystemMouseCursors.resizeLeftRight, + 1 => SystemMouseCursors.resizeUpLeftDownRight, + 2 => SystemMouseCursors.resizeUpDown, + _ => SystemMouseCursors.resizeUpRightDownLeft, + }; + } +} diff --git a/lib/shared/widgets/layer/layer_widget.dart b/lib/shared/widgets/layer/layer_widget.dart index e9c19e8b9..f2290a84d 100644 --- a/lib/shared/widgets/layer/layer_widget.dart +++ b/lib/shared/widgets/layer/layer_widget.dart @@ -230,6 +230,8 @@ class _LayerWidgetState extends State } // Start long press detection _longPressTimer?.cancel(); + // A press on a handle of the selection overlay reaches the layer too. + if (_layersService?.isScaleInteractionActive ?? false) return; _longPressTimer = Timer(_longPressThreshold, () { if (_lastDownEvent == null || _lastLayerOffset == null || @@ -428,6 +430,17 @@ class _LayerWidgetState extends State _layer, ), onScaleRotateUp: (_) => _layersService?.handleScaleRotateUp(), + onTextBoxResizeDown: (event, handle) { + final layer = _layer; + if (layer is TextLayer) { + _layersService?.handleTextBoxResizeDown( + layer, + handle, + event.position, + ); + } + }, + onTextBoxResizeUp: (_) => _layersService?.handleTextBoxResizeUp(), onRemoveLayer: () => _layersService?.handleRemoveLayer(_layer), onDuplicate: widget.onDuplicate, onGroupLayers: _layersService?.handleGroupLayers, diff --git a/lib/shared/widgets/layer/models/layer_item_interaction.dart b/lib/shared/widgets/layer/models/layer_item_interaction.dart index 06687009c..1c06f6316 100644 --- a/lib/shared/widgets/layer/models/layer_item_interaction.dart +++ b/lib/shared/widgets/layer/models/layer_item_interaction.dart @@ -1,5 +1,7 @@ import 'package:flutter/gestures.dart'; +import '/core/models/layers/text_layer_box.dart'; + /// Class that holds interactions for a layer item. class LayerItemInteractions { /// Constructor for LayerItemInteractions. @@ -13,8 +15,17 @@ class LayerItemInteractions { required this.scaleRotateUp, required this.group, required this.ungroup, + this.textBoxResizeDown = _ignoreTextBoxResizeDown, + this.textBoxResizeUp = _ignoreTextBoxResizeUp, }); + static void _ignoreTextBoxResizeDown( + PointerDownEvent event, + TextLayerBoxHandle handle, + ) {} + + static void _ignoreTextBoxResizeUp(PointerUpEvent event) {} + /// Callback function for editing the layer. final Function() edit; @@ -48,4 +59,13 @@ class LayerItemInteractions { /// This function ungroups the layer by removing its groupId, allowing it /// to be selected independently from other layers. final Function() ungroup; + + /// Called when a handle of a text layer's box is pressed: the drag that + /// follows resizes the box at [TextLayerBoxHandle] instead of moving the + /// layer. + final void Function(PointerDownEvent event, TextLayerBoxHandle handle) + textBoxResizeDown; + + /// Called when the box handle pressed with [textBoxResizeDown] is let go. + final void Function(PointerUpEvent event) textBoxResizeUp; } diff --git a/lib/shared/widgets/layer/widgets/layer_widget_text_item.dart b/lib/shared/widgets/layer/widgets/layer_widget_text_item.dart index 042671aa2..7b0589db9 100644 --- a/lib/shared/widgets/layer/widgets/layer_widget_text_item.dart +++ b/lib/shared/widgets/layer/widgets/layer_widget_text_item.dart @@ -180,8 +180,12 @@ class LayerWidgetTextItem extends StatefulWidget { finalStyle = style; } + final box = layer.box; + return RoundedBackgroundText.rich( enableHitBoxCorrection: true, + // The box is in the layer's units, like maxTextWidth. + box: box?.copyWith(size: box.size * layer.scale), maxTextWidth: maxTextWidth == null ? double.infinity : maxTextWidth * layer.scale, diff --git a/test/core/models/layers/text_layer_box_test.dart b/test/core/models/layers/text_layer_box_test.dart new file mode 100644 index 000000000..e00b38198 --- /dev/null +++ b/test/core/models/layers/text_layer_box_test.dart @@ -0,0 +1,207 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/core/models/layers/layer.dart'; +import 'package:pro_image_editor/features/main_editor/services/layer_copy_manager.dart'; +import 'package:pro_image_editor/shared/services/import_export/utils/key_minifier.dart'; + +void main() { + const box = TextLayerBox( + size: Size(200, 80), + verticalAlign: TextLayerBoxVerticalAlign.center, + overflow: TextLayerBoxOverflow.shrink, + ); + + TextLayer boxedLayer() => TextLayer(text: 'Name', box: box); + + group(TextLayerBox, () { + test('defaults to the top, clipping what does not fit', () { + const plain = TextLayerBox(size: Size(10, 20)); + + expect(plain.verticalAlign, TextLayerBoxVerticalAlign.top); + expect(plain.overflow, TextLayerBoxOverflow.clip); + }); + + test('survives a map round trip', () { + expect(TextLayerBox.fromMap(box.toMap()), box); + }); + + test('falls back to the defaults for values it does not know', () { + final restored = TextLayerBox.fromMap(const { + 'width': 10, + 'height': -5, + 'verticalAlign': 'baseline', + 'overflow': 'scroll', + }); + + expect(restored.size, const Size(10, 0)); + expect(restored.verticalAlign, TextLayerBoxVerticalAlign.top); + expect(restored.overflow, TextLayerBoxOverflow.clip); + }); + }); + + group('TextLayerBox.resize', () { + // A 100×40 box drawn at twice its size, placed by its top-left corner. + ({Size size, Offset offset}) resize( + TextLayerBoxHandle handle, + Offset delta, { + double rotation = 0, + bool flipX = false, + Offset fractionalOffset = Offset.zero, + }) { + return TextLayerBox.resize( + size: const Size(100, 40), + offset: const Offset(10, 20), + handle: handle, + delta: delta, + scale: 2, + rotation: rotation, + flipX: flipX, + fractionalOffset: fractionalOffset, + ); + } + + void expectOffset(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 1e-9)); + expect(actual.dy, closeTo(expected.dy, 1e-9)); + } + + test('grows to the right and down, keeping the top-left corner', () { + final resized = resize( + TextLayerBoxHandle.bottomRight, + const Offset(20, 10), + ); + + expect(resized.size, const Size(110, 45)); + expectOffset(resized.offset, const Offset(10, 20)); + }); + + test('grows to the left and up, moving the top-left corner', () { + final resized = resize( + TextLayerBoxHandle.topLeft, + const Offset(-20, -10), + ); + + expect(resized.size, const Size(110, 45)); + expectOffset(resized.offset, const Offset(-10, 10)); + }); + + test('an edge ignores the drag across it', () { + final resized = resize(TextLayerBoxHandle.right, const Offset(20, 50)); + + expect(resized.size, const Size(110, 40)); + expectOffset(resized.offset, const Offset(10, 20)); + }); + + test('keeps the opposite edge of a centered layer in place', () { + final resized = resize( + TextLayerBoxHandle.right, + const Offset(20, 0), + fractionalOffset: const Offset(-0.5, -0.5), + ); + + // The offset is the center, which moves half the growth. + expect(resized.size, const Size(110, 40)); + expectOffset(resized.offset, const Offset(20, 20)); + }); + + test('measures the drag along a turned layer', () { + // Turned a quarter, the right edge points down the canvas. + final resized = resize( + TextLayerBoxHandle.right, + const Offset(0, 20), + rotation: math.pi / 2, + fractionalOffset: const Offset(-0.5, -0.5), + ); + + expect(resized.size.width, closeTo(110, 1e-9)); + expect(resized.size.height, closeTo(40, 1e-9)); + expectOffset(resized.offset, const Offset(10, 30)); + }); + + test('follows a mirrored layer', () { + // Mirrored, the right edge of the box shows on the left. + final resized = resize( + TextLayerBoxHandle.right, + const Offset(-20, 0), + flipX: true, + fractionalOffset: const Offset(-0.5, -0.5), + ); + + expect(resized.size, const Size(110, 40)); + expectOffset(resized.offset, const Offset(0, 20)); + }); + + test('stops at the minimum size', () { + final resized = resize( + TextLayerBoxHandle.bottomRight, + const Offset(-1000, -1000), + ); + + expect(resized.size, TextLayerBox.minSize); + expectOffset(resized.offset, const Offset(10, 20)); + }); + }); + + group('TextLayer.box', () { + test('is null by default and left out of the map', () { + final layer = TextLayer(text: 'Name'); + + expect(layer.box, isNull); + expect(layer.toMap(), isNot(contains('box'))); + }); + + test('round-trips through Layer.fromMap', () { + final restored = Layer.fromMap(boxedLayer().toMap()) as TextLayer; + + expect(restored.box, box); + }); + + test('round-trips through minified keys', () { + final minifier = EditorKeyMinifier(enableMinify: true); + final minified = { + for (final entry in boxedLayer().toMap().entries) + minifier.convertLayerKey(entry.key): entry.value, + }; + + final restored = Layer.fromMap(minified, minifier: minifier) as TextLayer; + + expect(restored.box, box); + }); + + test('toMapFromReference writes the box only when it changed', () { + final reference = boxedLayer(); + + expect( + reference.copyWith(text: 'Other').toMapFromReference(reference), + isNot(contains('box')), + ); + + final resized = reference.copyWith( + box: box.copyWith(size: const Size(300, 80)), + ); + expect( + resized.toMapFromReference(reference)['box'], + resized.box!.toMap(), + ); + }); + + test('toMapFromReference writes null when the box was removed', () { + final reference = boxedLayer(); + final removed = reference.copyWith()..box = null; + + final map = removed.toMapFromReference(reference); + + expect(map, contains('box')); + expect(map['box'], isNull); + }); + + test('is kept by copyWith and copies', () { + final layer = boxedLayer(); + + expect(layer.copyWith(text: 'Other').box, box); + expect((LayerCopyManager().copyLayer(layer) as TextLayer).box, box); + }); + }); +} diff --git a/test/features/main_editor/services/text_box_resize_test.dart b/test/features/main_editor/services/text_box_resize_test.dart new file mode 100644 index 000000000..c085af71c --- /dev/null +++ b/test/features/main_editor/services/text_box_resize_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/core/models/editor_configs/pro_image_editor_configs.dart'; +import 'package:pro_image_editor/core/models/layers/layer.dart'; +import 'package:pro_image_editor/features/main_editor/services/layer_interaction_manager.dart'; + +void main() { + // The layers the editor shows, which the resize looks its layer up in. + late List shown; + + LayerInteractionManager manager() => LayerInteractionManager( + helperLinesCallbacks: null, + configs: const ProImageEditorConfigs( + textEditor: TextEditorConfigs(layerFractionalOffset: Offset.zero), + ), + onSelectedLayersChanged: null, + ); + + TextLayer boxedLayer() { + final layer = TextLayer( + text: 'Name', + offset: const Offset(10, 20), + scale: 2, + box: const TextLayerBox(size: Size(100, 40)), + ); + shown = [layer]; + return layer; + } + + void drag(LayerInteractionManager manager, Offset to, {double zoom = 1}) { + manager.calculateTextBoxResize( + details: ScaleUpdateDetails(focalPoint: to), + editorScaleFactor: zoom, + layers: shown, + ); + } + + group('LayerInteractionManager text box resize', () { + test('resizes from where the drag started', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.bottomRight); + + expect(interactions.isResizingTextBox, isTrue); + drag(interactions, const Offset(300, 300)); + expect(layer.box!.size, const Size(100, 40)); + + drag(interactions, const Offset(320, 310)); + expect(layer.box!.size, const Size(110, 45)); + expect(layer.offset, const Offset(10, 20)); + expect(layer.scale, 2); + }); + + test('measures the drag on the canvas, not on the zoomed screen', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.right); + + drag(interactions, const Offset(100, 0), zoom: 2); + drag(interactions, const Offset(140, 0), zoom: 2); + + expect(layer.box!.size, const Size(110, 40)); + }); + + test('keeps the alignment and overflow of the box', () { + final layer = boxedLayer() + ..box = const TextLayerBox( + size: Size(100, 40), + verticalAlign: TextLayerBoxVerticalAlign.center, + overflow: TextLayerBoxOverflow.shrink, + ); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.right); + + drag(interactions, Offset.zero); + drag(interactions, const Offset(20, 0)); + + expect(layer.box!.verticalAlign, TextLayerBoxVerticalAlign.center); + expect(layer.box!.overflow, TextLayerBoxOverflow.shrink); + }); + + test('resizes the copy the history step shows, by id', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.right); + // The gesture's history step replaces the shown layer with a copy. + final copy = layer.copyWith(); + shown = [copy]; + + drag(interactions, Offset.zero); + drag(interactions, const Offset(20, 0)); + + expect(copy.box!.size, const Size(110, 40)); + expect(layer.box!.size, const Size(100, 40)); + }); + + test('measures from where the handle was pressed', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize( + layer, + TextLayerBoxHandle.right, + pointerPosition: const Offset(300, 0), + ); + + drag(interactions, const Offset(320, 0)); + + expect(layer.box!.size, const Size(110, 40)); + }); + + test('ignores a layer without a box', () { + final layer = TextLayer(text: 'Free'); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.right); + + expect(interactions.isResizingTextBox, isFalse); + }); + + test('stops resizing once ended', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.right) + ..endTextBoxResize(); + + expect(interactions.isResizingTextBox, isFalse); + drag(interactions, Offset.zero); + drag(interactions, const Offset(20, 0)); + expect(layer.box!.size, const Size(100, 40)); + }); + }); +} diff --git a/test/features/main_editor/text_box_resize_editor_test.dart b/test/features/main_editor/text_box_resize_editor_test.dart new file mode 100644 index 000000000..bd6863d4f --- /dev/null +++ b/test/features/main_editor/text_box_resize_editor_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +import '../../mock/mock_image.dart'; + +/// Dragging a box handle in the editor resizes the box of a text layer and +/// leaves its scale alone. +void main() { + const configs = ProImageEditorConfigs( + progressIndicatorConfigs: ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: ImageGenerationConfigs( + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + ), + layerInteraction: LayerInteractionConfigs( + selectable: LayerInteractionSelectable.enabled, + enableTextBoxResize: true, + ), + ); + + // Placed by its top-left corner, so resizing from the right or bottom + // edge leaves the layer's offset alone. + final topLeftConfigs = configs.copyWith( + textEditor: const TextEditorConfigs(layerFractionalOffset: Offset.zero), + ); + + Future pumpEditor( + WidgetTester tester, { + ProImageEditorConfigs editorConfigs = configs, + }) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: editorConfigs, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: (Uint8List bytes) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; + } + + Future mouseDrag( + WidgetTester tester, { + required Offset from, + required Offset by, + }) async { + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton, + ); + await tester.pump(); + await gesture.moveBy(by / 2); + await tester.pump(); + await gesture.moveBy(by / 2); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + } + + testWidgets('a box handle resizes the box, not the layer', (tester) async { + final state = await pumpEditor(tester); + final layer = TextLayer( + text: 'Name', + scale: 1, + box: const TextLayerBox(size: Size(120, 40)), + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pumpAndSettle(); + state.selectLayerById(layer.id); + await tester.pumpAndSettle(); + + expect(find.byType(TextLayerBoxHandles), findsOneWidget); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + + await mouseDrag( + tester, + from: Offset(text.right, text.center.dy), + by: const Offset(40, 0), + ); + + final live = state.activeLayers.single as TextLayer; + expect(live.scale, 1); + // The edge follows the whole drag: 40 px, at a scale of 1. + expect(live.box!.size.width, closeTo(160, 0.001)); + expect(live.box!.size.height, 40); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + group('a held box handle', () { + Future<(ProImageEditorState, TextLayer)> pumpBoxedLayer( + WidgetTester tester, + ) async { + final state = await pumpEditor(tester, editorConfigs: topLeftConfigs); + final layer = TextLayer( + text: 'Name', + scale: 1, + box: const TextLayerBox(size: Size(120, 40)), + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pumpAndSettle(); + state.selectLayerById(layer.id); + await tester.pumpAndSettle(); + return (state, layer); + } + + TextLayer live(ProImageEditorState state) => + state.activeLayers.single as TextLayer; + + Future press(WidgetTester tester, Offset at) async { + final gesture = await tester.startGesture( + at, + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton, + ); + await tester.pump(); + return gesture; + } + + testWidgets('keeps resizing when held still past a long press', ( + tester, + ) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + final offset = layer.offset; + + final gesture = await press(tester, Offset(text.right, text.center.dy)); + await tester.pump(const Duration(milliseconds: 700)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(live(state).box!.size.width, closeTo(160, 0.001)); + expect(live(state).offset, offset); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('keeps resizing through pauses longer than a long press', ( + tester, + ) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + + final gesture = await press(tester, Offset(text.center.dx, text.bottom)); + for (var step = 1; step <= 3; step++) { + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(const Duration(milliseconds: 600)); + expect(live(state).box!.size.height, closeTo(40 + step * 10, 0.001)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + } + expect(live(state).box!.size.width, 120); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(live(state).box!.size.height, closeTo(70, 0.001)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + }); +} diff --git a/test/features/text_editor/rounded_background_text_box_test.dart b/test/features/text_editor/rounded_background_text_box_test.dart new file mode 100644 index 000000000..fd53b8867 --- /dev/null +++ b/test/features/text_editor/rounded_background_text_box_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/features/text_editor/utils/rounded_background_painter.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/widgets/layer/widgets/layer_widget_text_item.dart'; + +void main() { + const style = TextStyle(fontSize: 20, color: Color(0xFF000000)); + + Future pumpText(WidgetTester tester, Widget text) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Center(child: text)), + ), + ); + } + + RoundedBackgroundTextPainter textPainter(WidgetTester tester) { + final paint = tester.widget( + find.descendant( + of: find.byType(RoundedBackgroundText), + matching: find.byType(CustomPaint), + ), + ); + return paint.painter! as RoundedBackgroundTextPainter; + } + + RoundedBackgroundText boxedText( + String text, + TextLayerBox box, { + Function(bool hasHit)? onHitTestResult, + }) { + return RoundedBackgroundText( + text, + style: style, + maxTextWidth: double.infinity, + enableHitBoxCorrection: true, + backgroundColor: const Color(0x80000000), + onHitTestResult: onHitTestResult, + box: box, + ); + } + + group('RoundedBackgroundText.box', () { + testWidgets('takes the size of the box, not of its text', (tester) async { + await pumpText( + tester, + boxedText('Hi', const TextLayerBox(size: Size(300, 120))), + ); + + expect( + tester.getSize(find.byType(RoundedBackgroundText)), + const Size(300, 120), + ); + }); + + testWidgets('wraps the text at the box width', (tester) async { + await pumpText( + tester, + boxedText( + 'one two three four', + const TextLayerBox(size: Size(80, 200)), + ), + ); + + final painter = textPainter(tester).painter; + expect(painter.computeLineMetrics().length, greaterThan(1)); + expect( + painter.width, + 80 - textPainter(tester).hitBoxCorrectionOffset.dx * 2, + ); + }); + + for (final (align, factor) in [ + (TextLayerBoxVerticalAlign.top, 0.0), + (TextLayerBoxVerticalAlign.center, 0.5), + (TextLayerBoxVerticalAlign.bottom, 1.0), + ]) { + testWidgets('places short text at the ${align.name}', (tester) async { + await pumpText( + tester, + boxedText( + 'Hi', + TextLayerBox(size: const Size(300, 120), verticalAlign: align), + ), + ); + + final painter = textPainter(tester); + final lineHeight = painter.painter.preferredLineHeight; + final padding = lineHeight * 0.1; + final free = 120 - padding * 2 - painter.painter.height; + expect( + painter.hitBoxCorrectionOffset.dy, + closeTo(padding + free * factor, 0.001), + ); + }); + } + + testWidgets('keeps the font size of text that fits when shrinking', ( + tester, + ) async { + await pumpText( + tester, + boxedText( + 'Hi', + const TextLayerBox( + size: Size(300, 120), + overflow: TextLayerBoxOverflow.shrink, + ), + ), + ); + + expect(textPainter(tester).painter.textScaler, TextScaler.noScaling); + }); + + testWidgets('shrinks text taller than the box until it fits', ( + tester, + ) async { + const box = TextLayerBox( + size: Size(120, 60), + overflow: TextLayerBoxOverflow.shrink, + ); + await pumpText( + tester, + boxedText('one two three four five six seven eight', box), + ); + + final painter = textPainter(tester); + final factor = painter.painter.textScaler.scale(1); + expect(factor, lessThan(1)); + expect(factor, greaterThanOrEqualTo(TextLayerBox.minShrinkFactor)); + final padding = painter.painter.preferredLineHeight * 0.1; + expect(painter.painter.height, lessThanOrEqualTo(60 - padding * 2)); + }); + + testWidgets('clips text taller than the box by default', (tester) async { + await pumpText( + tester, + boxedText( + 'one two three four five six seven eight', + const TextLayerBox(size: Size(120, 60)), + ), + ); + + final painter = textPainter(tester); + expect(painter.painter.textScaler, TextScaler.noScaling); + expect(painter.painter.height, greaterThan(60)); + expect( + tester.renderObject(find.byType(RoundedBackgroundText)), + paints..clipRect(rect: const Rect.fromLTWH(0, 0, 120, 60)), + ); + }); + + testWidgets('counts a hit anywhere in the box', (tester) async { + final hits = []; + await pumpText( + tester, + boxedText( + 'Hi', + const TextLayerBox(size: Size(300, 120)), + onHitTestResult: hits.add, + ), + ); + + final painter = textPainter(tester); + expect(painter.hitTest(const Offset(290, 110)), isTrue); + expect(painter.hitTest(const Offset(310, 110)), isFalse); + expect(hits, [true, false]); + }); + }); + + group('LayerWidgetTextItem with a box', () { + testWidgets('scales the box with the layer', (tester) async { + final showMoveCursor = ValueNotifier(false); + addTearDown(showMoveCursor.dispose); + + await pumpText( + tester, + LayerWidgetTextItem( + layer: TextLayer( + text: 'Name', + scale: 1.5, + box: const TextLayerBox(size: Size(200, 40)), + ), + textEditorConfigs: const TextEditorConfigs(), + showMoveCursor: showMoveCursor, + onHitChanged: (_) {}, + ), + ); + + expect( + tester.getSize(find.byType(RoundedBackgroundText)), + const Size(300, 60), + ); + }); + }); +} diff --git a/test/features/text_editor/text_editor_box_test.dart b/test/features/text_editor/text_editor_box_test.dart new file mode 100644 index 000000000..52dc21ad2 --- /dev/null +++ b/test/features/text_editor/text_editor_box_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/features/text_editor/utils/rounded_background_painter.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +void main() { + final key = GlobalKey(); + + Future edit( + WidgetTester tester, + TextLayer? layer, + void Function(TextEditorState editor) change, + ) async { + TextLayer? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TextEditor( + key: key, + theme: ThemeData.dark(), + layer: layer, + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final editor = key.currentState!; + change(editor); + editor.done(); + await tester.pumpAndSettle(); + return result; + } + + group('TextEditor box', () { + testWidgets('is off for a layer without a box', (tester) async { + final result = await edit( + tester, + TextLayer(text: 'Hello'), + (editor) => expect(editor.boxEnabled, isFalse), + ); + + expect(result!.box, isNull); + }); + + testWidgets('fits a new box around the text', (tester) async { + final result = await edit( + tester, + TextLayer(text: 'Hello', scale: 2), + (editor) => editor.setBoxEnabled(true), + ); + + final box = result!.box!; + expect(box.size.width, greaterThan(0)); + expect(box.size.height, greaterThan(0)); + expect(box.overflow, TextLayerBoxOverflow.shrink); + expect(box.verticalAlign, TextLayerBoxVerticalAlign.top); + }); + + testWidgets('keeps the size of an existing box', (tester) async { + const box = TextLayerBox(size: Size(300, 90)); + final result = await edit( + tester, + TextLayer(text: 'Hello', box: box), + (editor) { + expect(editor.boxEnabled, isTrue); + editor.textCtrl.text = 'Hello there'; + }, + ); + + expect(result!.box, box); + }); + + testWidgets('takes the text out of its box', (tester) async { + final result = await edit( + tester, + TextLayer(text: 'Hello', box: const TextLayerBox(size: Size(300, 90))), + (editor) => editor.setBoxEnabled(false), + ); + + expect(result!.box, isNull); + }); + }); + + group('TextLayerBox.fit', () { + testWidgets('shows the text as it was, at another scale', (tester) async { + const text = 'Roll for initiative\nand hope'; + const style = TextStyle(fontSize: 20); + final box = TextLayerBox.fit(text: text, style: style, slack: 1); + + // The layer drawn at three times its size. + const scale = 3.0; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RoundedBackgroundText( + text, + style: style.copyWith(fontSize: 20 * scale), + maxTextWidth: double.infinity, + enableHitBoxCorrection: true, + box: box.copyWith(size: box.size * scale), + ), + ), + ), + ); + + final paint = tester.widget( + find.descendant( + of: find.byType(RoundedBackgroundText), + matching: find.byType(CustomPaint), + ), + ); + final painter = (paint.painter! as RoundedBackgroundTextPainter).painter; + expect(painter.textScaler, TextScaler.noScaling); + expect(painter.computeLineMetrics(), hasLength(2)); + }); + }); +} diff --git a/test/shared/widgets/layer/text_layer_box_handles_test.dart b/test/shared/widgets/layer/text_layer_box_handles_test.dart new file mode 100644 index 000000000..b7c1cfbde --- /dev/null +++ b/test/shared/widgets/layer/text_layer_box_handles_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:pro_image_editor/core/models/layers/layer.dart'; +import 'package:pro_image_editor/shared/widgets/layer/interaction_helper/text_layer_box_handles.dart'; + +void main() { + group(TextLayerBoxHandles, () { + testWidgets('reports the pressed handle and lets the drag through', ( + tester, + ) async { + final pressed = []; + var released = 0; + var dragged = Offset.zero; + + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + // Stands in for the editor's own drag gesture, which does the + // resizing. + Positioned.fill( + child: GestureDetector( + onPanUpdate: (details) => dragged += details.delta, + ), + ), + Positioned( + left: 100, + top: 100, + width: 216, + height: 116, + child: TextLayerBoxHandles( + boxInset: const EdgeInsets.all(8), + onResizeDown: (_, handle) => pressed.add(handle), + onResizeUp: (_) => released++, + ), + ), + ], + ), + ), + ); + + // The right edge of the 200×100 box, halfway down. + final gesture = await tester.startGesture(const Offset(308, 158)); + await gesture.moveBy(const Offset(40, 0)); + await gesture.moveBy(const Offset(10, 0)); + await gesture.up(); + + expect(pressed, [TextLayerBoxHandle.right]); + expect(released, 1); + expect(dragged.dx, greaterThan(0)); + }); + }); +} From 6376c4dd1b721ec26d4913da6228b14388bf53b8 Mon Sep 17 00:00:00 2001 From: Saif Addin Date: Tue, 20 Oct 2026 21:08:09 +0200 Subject: [PATCH 2/4] feat(main-editor): resizable text boxes example --- .../lib/features/layer/layer_group_page.dart | 7 + .../lib/features/layer/text_box_example.dart | 115 ++++++++++++++ .../text_box_resize_editor_test.dart | 145 ++++++++++-------- .../text_editor/text_editor_box_test.dart | 19 +-- 4 files changed, 210 insertions(+), 76 deletions(-) create mode 100644 example/lib/features/layer/text_box_example.dart diff --git a/example/lib/features/layer/layer_group_page.dart b/example/lib/features/layer/layer_group_page.dart index a3d466d84..f2a4969ee 100644 --- a/example/lib/features/layer/layer_group_page.dart +++ b/example/lib/features/layer/layer_group_page.dart @@ -5,6 +5,7 @@ import '/features/layer/layer_grouping_example.dart'; import '/features/layer/layer_rasterizer_example.dart'; import '/features/layer/layer_select_design_example.dart'; import '/features/layer/selectable_layer_example.dart'; +import '/features/layer/text_box_example.dart'; /// A [StatefulWidget] that represents the AI group page in the application. class LayerGroupPage extends StatefulWidget { @@ -44,6 +45,12 @@ class _LayerGroupPageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _openExample(const SelectableLayerExample()), ), + ListTile( + leading: const Icon(Icons.crop_free), + title: const Text('Resizable Text Boxes'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openExample(const TextBoxExample()), + ), ListTile( leading: const Icon(Icons.image_outlined), title: const Text('Export Layers as PNG'), diff --git a/example/lib/features/layer/text_box_example.dart b/example/lib/features/layer/text_box_example.dart new file mode 100644 index 000000000..98ca7c9ab --- /dev/null +++ b/example/lib/features/layer/text_box_example.dart @@ -0,0 +1,115 @@ +// Flutter imports: +import 'package:material_ui/material_ui.dart'; + +// Package imports: +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 text layers in a fixed, resizable box. +/// +/// The editor opens with a boxed text layer already on the image. Drag the +/// handles on its edges to resize the box: the text wraps and shrinks to fit, +/// but its size does not change. The text editor gets a "Text box" chip +/// that puts new or edited text into a box, or takes it out of one. +/// +/// Example usage: +/// ```dart +/// TextBoxExample(); +/// ``` +class TextBoxExample extends StatefulWidget { + /// Creates a new [TextBoxExample] widget. + const TextBoxExample({super.key}); + + @override + State createState() => _TextBoxExampleState(); +} + +/// The state for the [TextBoxExample] widget. +class _TextBoxExampleState extends State + with ExampleHelperState { + @override + void initState() { + super.initState(); + preCacheImage(assetPath: kImageEditorExampleAssetPath); + } + + void _onAfterViewInit() { + editorKey.currentState!.addLayer( + TextLayer( + text: 'Drag the handles on the edges to resize this box. ' + 'The text wraps and shrinks to fit.', + box: const TextLayerBox( + size: Size(260, 120), + verticalAlign: TextLayerBoxVerticalAlign.center, + overflow: TextLayerBoxOverflow.shrink, + ), + ), + ); + } + + @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), + ), + layerInteraction: const LayerInteractionConfigs( + /// Shows the selection, and with it the box handles, on mobile too. + selectable: LayerInteractionSelectable.enabled, + + /// Shows handles on the edges of a text layer with a box. + enableTextBoxResize: true, + ), + textEditor: TextEditorConfigs( + widgets: TextEditorWidgets( + bodyItemsOverlay: (textEditor, rebuildStream) => [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => _buildBoxToggle(textEditor), + ), + ], + ), + ), + ), + ); + } + + /// A chip that puts the text into a box, or takes it out of one. + /// + /// Turning it on fits the box around the text as it is, so the layer + /// looks the same until its box is resized. + Widget _buildBoxToggle(TextEditorState textEditor) { + return Positioned( + top: 12, + left: 12, + child: FilterChip( + avatar: const Icon(Icons.crop_free), + label: const Text('Text box'), + selected: textEditor.boxEnabled, + showCheckmark: false, + onSelected: textEditor.setBoxEnabled, + ), + ); + } +} diff --git a/test/features/main_editor/text_box_resize_editor_test.dart b/test/features/main_editor/text_box_resize_editor_test.dart index bd6863d4f..53c862c82 100644 --- a/test/features/main_editor/text_box_resize_editor_test.dart +++ b/test/features/main_editor/text_box_resize_editor_test.dart @@ -72,37 +72,41 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('a box handle resizes the box, not the layer', (tester) async { - final state = await pumpEditor(tester); - final layer = TextLayer( - text: 'Name', - scale: 1, - box: const TextLayerBox(size: Size(120, 40)), - ); - state.addLayer( - layer, - autoCorrectZoomOffset: false, - autoCorrectZoomScale: false, - ); - await tester.pumpAndSettle(); - state.selectLayerById(layer.id); - await tester.pumpAndSettle(); + testWidgets( + 'a box handle resizes the box, not the layer', + (tester) async { + final state = await pumpEditor(tester); + final layer = TextLayer( + text: 'Name', + scale: 1, + box: const TextLayerBox(size: Size(120, 40)), + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pumpAndSettle(); + state.selectLayerById(layer.id); + await tester.pumpAndSettle(); - expect(find.byType(TextLayerBoxHandles), findsOneWidget); - final text = tester.getRect(find.byType(RoundedBackgroundText)); + expect(find.byType(TextLayerBoxHandles), findsOneWidget); + final text = tester.getRect(find.byType(RoundedBackgroundText)); - await mouseDrag( - tester, - from: Offset(text.right, text.center.dy), - by: const Offset(40, 0), - ); + await mouseDrag( + tester, + from: Offset(text.right, text.center.dy), + by: const Offset(40, 0), + ); - final live = state.activeLayers.single as TextLayer; - expect(live.scale, 1); - // The edge follows the whole drag: 40 px, at a scale of 1. - expect(live.box!.size.width, closeTo(160, 0.001)); - expect(live.box!.size.height, 40); - }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + final live = state.activeLayers.single as TextLayer; + expect(live.scale, 1); + // The edge follows the whole drag: 40 px, at a scale of 1. + expect(live.box!.size.width, closeTo(160, 0.001)); + expect(live.box!.size.height, 40); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); group('a held box handle', () { Future<(ProImageEditorState, TextLayer)> pumpBoxedLayer( @@ -138,49 +142,56 @@ void main() { return gesture; } - testWidgets('keeps resizing when held still past a long press', ( - tester, - ) async { - final (state, layer) = await pumpBoxedLayer(tester); - final text = tester.getRect(find.byType(RoundedBackgroundText)); - final offset = layer.offset; - - final gesture = await press(tester, Offset(text.right, text.center.dy)); - await tester.pump(const Duration(milliseconds: 700)); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); - - await gesture.moveBy(const Offset(20, 0)); - await tester.pump(); - await gesture.moveBy(const Offset(20, 0)); - await tester.pump(); - expect(live(state).box!.size.width, closeTo(160, 0.001)); - expect(live(state).offset, offset); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); + testWidgets( + 'keeps resizing when held still past a long press', + (tester) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + final offset = layer.offset; - await gesture.up(); - await tester.pumpAndSettle(); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); - }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + final gesture = await press(tester, Offset(text.right, text.center.dy)); + await tester.pump(const Duration(milliseconds: 700)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); - testWidgets('keeps resizing through pauses longer than a long press', ( - tester, - ) async { - final (state, layer) = await pumpBoxedLayer(tester); - final text = tester.getRect(find.byType(RoundedBackgroundText)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(live(state).box!.size.width, closeTo(160, 0.001)); + expect(live(state).offset, offset); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); - final gesture = await press(tester, Offset(text.center.dx, text.bottom)); - for (var step = 1; step <= 3; step++) { - await gesture.moveBy(const Offset(0, 10)); - await tester.pump(const Duration(milliseconds: 600)); - expect(live(state).box!.size.height, closeTo(40 + step * 10, 0.001)); + await gesture.up(); + await tester.pumpAndSettle(); expect(state.selectedLayers.map((l) => l.id), [layer.id]); - } - expect(live(state).box!.size.width, 120); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); - await gesture.up(); - await tester.pumpAndSettle(); - expect(live(state).box!.size.height, closeTo(70, 0.001)); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); - }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + testWidgets( + 'keeps resizing through pauses longer than a long press', + (tester) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + + final gesture = await press( + tester, + Offset(text.center.dx, text.bottom), + ); + for (var step = 1; step <= 3; step++) { + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(const Duration(milliseconds: 600)); + expect(live(state).box!.size.height, closeTo(40 + step * 10, 0.001)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + } + expect(live(state).box!.size.width, 120); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(live(state).box!.size.height, closeTo(70, 0.001)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); }); } diff --git a/test/features/text_editor/text_editor_box_test.dart b/test/features/text_editor/text_editor_box_test.dart index 52dc21ad2..eb9edb6e6 100644 --- a/test/features/text_editor/text_editor_box_test.dart +++ b/test/features/text_editor/text_editor_box_test.dart @@ -70,14 +70,12 @@ void main() { testWidgets('keeps the size of an existing box', (tester) async { const box = TextLayerBox(size: Size(300, 90)); - final result = await edit( - tester, - TextLayer(text: 'Hello', box: box), - (editor) { - expect(editor.boxEnabled, isTrue); - editor.textCtrl.text = 'Hello there'; - }, - ); + final result = await edit(tester, TextLayer(text: 'Hello', box: box), ( + editor, + ) { + expect(editor.boxEnabled, isTrue); + editor.textCtrl.text = 'Hello there'; + }); expect(result!.box, box); }); @@ -85,7 +83,10 @@ void main() { testWidgets('takes the text out of its box', (tester) async { final result = await edit( tester, - TextLayer(text: 'Hello', box: const TextLayerBox(size: Size(300, 90))), + TextLayer( + text: 'Hello', + box: const TextLayerBox(size: Size(300, 90)), + ), (editor) => editor.setBoxEnabled(false), ); From 2a3dedf980ed005bc00d2f70e9a454eebf967b3b Mon Sep 17 00:00:00 2001 From: Saif Addin Date: Tue, 20 Oct 2026 22:10:52 +0200 Subject: [PATCH 3/4] feat(main-editor): resizable text boxes format --- .../text_box_resize_editor_test.dart | 145 ++++++++---------- 1 file changed, 67 insertions(+), 78 deletions(-) diff --git a/test/features/main_editor/text_box_resize_editor_test.dart b/test/features/main_editor/text_box_resize_editor_test.dart index 53c862c82..bd6863d4f 100644 --- a/test/features/main_editor/text_box_resize_editor_test.dart +++ b/test/features/main_editor/text_box_resize_editor_test.dart @@ -72,41 +72,37 @@ void main() { await tester.pumpAndSettle(); } - testWidgets( - 'a box handle resizes the box, not the layer', - (tester) async { - final state = await pumpEditor(tester); - final layer = TextLayer( - text: 'Name', - scale: 1, - box: const TextLayerBox(size: Size(120, 40)), - ); - state.addLayer( - layer, - autoCorrectZoomOffset: false, - autoCorrectZoomScale: false, - ); - await tester.pumpAndSettle(); - state.selectLayerById(layer.id); - await tester.pumpAndSettle(); + testWidgets('a box handle resizes the box, not the layer', (tester) async { + final state = await pumpEditor(tester); + final layer = TextLayer( + text: 'Name', + scale: 1, + box: const TextLayerBox(size: Size(120, 40)), + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pumpAndSettle(); + state.selectLayerById(layer.id); + await tester.pumpAndSettle(); - expect(find.byType(TextLayerBoxHandles), findsOneWidget); - final text = tester.getRect(find.byType(RoundedBackgroundText)); + expect(find.byType(TextLayerBoxHandles), findsOneWidget); + final text = tester.getRect(find.byType(RoundedBackgroundText)); - await mouseDrag( - tester, - from: Offset(text.right, text.center.dy), - by: const Offset(40, 0), - ); + await mouseDrag( + tester, + from: Offset(text.right, text.center.dy), + by: const Offset(40, 0), + ); - final live = state.activeLayers.single as TextLayer; - expect(live.scale, 1); - // The edge follows the whole drag: 40 px, at a scale of 1. - expect(live.box!.size.width, closeTo(160, 0.001)); - expect(live.box!.size.height, 40); - }, - variant: TargetPlatformVariant.only(TargetPlatform.linux), - ); + final live = state.activeLayers.single as TextLayer; + expect(live.scale, 1); + // The edge follows the whole drag: 40 px, at a scale of 1. + expect(live.box!.size.width, closeTo(160, 0.001)); + expect(live.box!.size.height, 40); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); group('a held box handle', () { Future<(ProImageEditorState, TextLayer)> pumpBoxedLayer( @@ -142,56 +138,49 @@ void main() { return gesture; } - testWidgets( - 'keeps resizing when held still past a long press', - (tester) async { - final (state, layer) = await pumpBoxedLayer(tester); - final text = tester.getRect(find.byType(RoundedBackgroundText)); - final offset = layer.offset; + testWidgets('keeps resizing when held still past a long press', ( + tester, + ) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + final offset = layer.offset; - final gesture = await press(tester, Offset(text.right, text.center.dy)); - await tester.pump(const Duration(milliseconds: 700)); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); + final gesture = await press(tester, Offset(text.right, text.center.dy)); + await tester.pump(const Duration(milliseconds: 700)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); - await gesture.moveBy(const Offset(20, 0)); - await tester.pump(); - await gesture.moveBy(const Offset(20, 0)); - await tester.pump(); - expect(live(state).box!.size.width, closeTo(160, 0.001)); - expect(live(state).offset, offset); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(live(state).box!.size.width, closeTo(160, 0.001)); + expect(live(state).offset, offset); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); - await gesture.up(); - await tester.pumpAndSettle(); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); - }, - variant: TargetPlatformVariant.only(TargetPlatform.linux), - ); + await gesture.up(); + await tester.pumpAndSettle(); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); - testWidgets( - 'keeps resizing through pauses longer than a long press', - (tester) async { - final (state, layer) = await pumpBoxedLayer(tester); - final text = tester.getRect(find.byType(RoundedBackgroundText)); - - final gesture = await press( - tester, - Offset(text.center.dx, text.bottom), - ); - for (var step = 1; step <= 3; step++) { - await gesture.moveBy(const Offset(0, 10)); - await tester.pump(const Duration(milliseconds: 600)); - expect(live(state).box!.size.height, closeTo(40 + step * 10, 0.001)); - expect(state.selectedLayers.map((l) => l.id), [layer.id]); - } - expect(live(state).box!.size.width, 120); - - await gesture.up(); - await tester.pumpAndSettle(); - expect(live(state).box!.size.height, closeTo(70, 0.001)); + testWidgets('keeps resizing through pauses longer than a long press', ( + tester, + ) async { + final (state, layer) = await pumpBoxedLayer(tester); + final text = tester.getRect(find.byType(RoundedBackgroundText)); + + final gesture = await press(tester, Offset(text.center.dx, text.bottom)); + for (var step = 1; step <= 3; step++) { + await gesture.moveBy(const Offset(0, 10)); + await tester.pump(const Duration(milliseconds: 600)); + expect(live(state).box!.size.height, closeTo(40 + step * 10, 0.001)); expect(state.selectedLayers.map((l) => l.id), [layer.id]); - }, - variant: TargetPlatformVariant.only(TargetPlatform.linux), - ); + } + expect(live(state).box!.size.width, 120); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(live(state).box!.size.height, closeTo(70, 0.001)); + expect(state.selectedLayers.map((l) => l.id), [layer.id]); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); }); } From 1ecd8846f79daecab80e5bee6368afceb7a6e1c0 Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 21 Oct 2026 08:44:34 +0200 Subject: [PATCH 4/4] fix(main-editor): resize text boxes with keyframes, round and shrink effects - A box handle on a layer with keyframes kept the edge opposite it only on the rest offset: the resize now starts from where the keyframes show the layer and writes the keyframe at the playhead, like the other transform gestures. - TextLayerBox.toMap rounds its size to maxDecimalPlaces, which toMap and toMapFromReference pass on; fromMap also reads sizes written as text. - A box that shrinks its text shrinks the outline and the shadows with it. - Add the 14.15.0 CHANGELOG entry and version. --- CHANGELOG.md | 3 + lib/core/models/layers/text_layer.dart | 5 +- lib/core/models/layers/text_layer_box.dart | 24 ++++-- lib/features/main_editor/main_editor.dart | 1 + .../services/layer_interaction_manager.dart | 21 ++--- .../rounded_background_text.dart | 27 ++++-- pubspec.yaml | 2 +- .../models/layers/text_layer_box_test.dart | 30 +++++++ .../services/text_box_resize_test.dart | 15 ++++ .../text_box_resize_editor_test.dart | 86 +++++++++++++++++++ .../rounded_background_text_box_test.dart | 32 +++++++ 11 files changed, 213 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbd09a16..d40d4acf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 14.15.0 +- **FEAT**(layers): Add `TextLayer.box` to lay a text out in a fixed box that clips or shrinks text that does not fit; `LayerInteractionConfigs.enableTextBoxResize` shows handles that resize the box without scaling the text, and `TextEditorState.setBoxEnabled` puts the edited text into a box. + ## 14.14.3 - **PERF**(import-export): History steps that leave a paint layer's strokes unchanged share its points instead of each importing a copy, so opening a long history over a large drawing no longer takes hundreds of MB and several seconds. `PaintedModel.copy` now shares `offsets` with the original. diff --git a/lib/core/models/layers/text_layer.dart b/lib/core/models/layers/text_layer.dart index 207047ee3..5c3b96306 100644 --- a/lib/core/models/layers/text_layer.dart +++ b/lib/core/models/layers/text_layer.dart @@ -420,7 +420,7 @@ class TextLayer extends Layer { 'type': 'text', if (maxTextWidth != null) 'maxTextWidth': maxTextWidth?.roundSmart(maxDecimalPlaces), - if (box != null) 'box': box!.toMap(), + if (box != null) 'box': box!.toMap(maxDecimalPlaces: maxDecimalPlaces), if (customSecondaryColor) 'customSecondaryColor': customSecondaryColor, if (highlights.isNotEmpty) 'highlights': highlights.map((h) => h.toMap()).toList(), @@ -496,7 +496,8 @@ class TextLayer extends Layer { 'maxTextWidth': maxTextWidth?.roundSmart(maxDecimalPlaces), // `null` is written too, so a removed box does not come back from the // previous step. - if (paintLayer.box != box) 'box': box?.toMap(), + if (paintLayer.box != box) + 'box': box?.toMap(maxDecimalPlaces: maxDecimalPlaces), // An empty list is written on purpose: the import merges each entry // over the previous state, so a missing key would restore shadows the // user removed. diff --git a/lib/core/models/layers/text_layer_box.dart b/lib/core/models/layers/text_layer_box.dart index 5cc4191fa..d0bc4622d 100644 --- a/lib/core/models/layers/text_layer_box.dart +++ b/lib/core/models/layers/text_layer_box.dart @@ -3,6 +3,10 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart' show immutable; import 'package:flutter/painting.dart'; +import '/core/constants/int_constants.dart'; +import '/shared/extensions/num_extension.dart'; +import '/shared/utils/parser/double_parser.dart'; + /// Where the text of a [TextLayerBox] sits when it is shorter than the box. enum TextLayerBoxVerticalAlign { /// The first line starts at the top of the box. @@ -103,8 +107,8 @@ class TextLayerBox { factory TextLayerBox.fromMap(Map map) { return TextLayerBox( size: Size( - math.max(0, (map['width'] as num?)?.toDouble() ?? 0), - math.max(0, (map['height'] as num?)?.toDouble() ?? 0), + math.max(0, safeParseDouble(map['width'])), + math.max(0, safeParseDouble(map['height'])), ), verticalAlign: TextLayerBoxVerticalAlign.values.firstWhere( (align) => align.name == map['verticalAlign'], @@ -240,13 +244,15 @@ class TextLayerBox { ); } - /// Converts the box to a map, readable by [TextLayerBox.fromMap]. - Map toMap() => { - 'width': size.width, - 'height': size.height, - 'verticalAlign': verticalAlign.name, - 'overflow': overflow.name, - }; + /// Converts the box to a map, readable by [TextLayerBox.fromMap], with its + /// size rounded to [maxDecimalPlaces]. + Map toMap({int maxDecimalPlaces = kMaxSafeDecimalPlaces}) => + { + 'width': size.width.roundSmart(maxDecimalPlaces), + 'height': size.height.roundSmart(maxDecimalPlaces), + 'verticalAlign': verticalAlign.name, + 'overflow': overflow.name, + }; /// Creates a copy with the given fields replaced. TextLayerBox copyWith({ diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index b663dca3e..09bf0c960 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -1558,6 +1558,7 @@ class ProImageEditorState extends State layers: activeLayers, ); if (layer != null) { + _recordKeyframeEdit(); mainEditorCallbacks?.handleUpdateLayer(layer); layer.key.currentState?.setState(() {}); } diff --git a/lib/features/main_editor/services/layer_interaction_manager.dart b/lib/features/main_editor/services/layer_interaction_manager.dart index 0503406ef..87744169b 100644 --- a/lib/features/main_editor/services/layer_interaction_manager.dart +++ b/lib/features/main_editor/services/layer_interaction_manager.dart @@ -398,8 +398,6 @@ class LayerInteractionManager { activeInteractionLayer = layer; textBoxResizeHandle = handle; _boxResizeLayerId = layer.id; - _boxResizeStartBox = layer.box; - _boxResizeStartOffset = layer.offset; _boxResizeStartPointer = pointerPosition; } @@ -417,9 +415,10 @@ class LayerInteractionManager { /// [textBoxResizeHandle] in place, and returns that layer. /// /// The layer is looked up by id in [layers]: the history step the gesture - /// starts with copies the layers, and the copies are the ones shown. The - /// drag is measured on screen and divided by [editorScaleFactor], the - /// zoom of the canvas. + /// starts with copies the layers, and the copies are the ones shown. Its + /// box and offset are taken at the first update, once a layer with + /// keyframes has been laid out where they show it. The drag is measured on + /// screen and divided by [editorScaleFactor], the zoom of the canvas. TextLayer? calculateTextBoxResize({ required ScaleUpdateDetails details, required double editorScaleFactor, @@ -429,14 +428,10 @@ class LayerInteractionManager { .where((layer) => layer.id == _boxResizeLayerId) .firstOrNull; final handle = textBoxResizeHandle; - final startBox = _boxResizeStartBox; - final startOffset = _boxResizeStartOffset; - if (layer is! TextLayer || - handle == null || - startBox == null || - startOffset == null) { - return null; - } + if (layer is! TextLayer || handle == null) return null; + final startBox = _boxResizeStartBox ??= layer.box; + final startOffset = _boxResizeStartOffset ??= layer.offset; + if (startBox == null) return null; final pointer = details.focalPoint; final startPointer = _boxResizeStartPointer ??= pointer; diff --git a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart index 11c4c78ec..bed257651 100644 --- a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart +++ b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart @@ -211,7 +211,9 @@ class RoundedBackgroundText extends StatelessWidget { final shadows = style.shadows ?? const []; final hasOutline = _hasOutline; - var textScaler = TextScaler.noScaling; + // Below 1 while a box shrinks its text to fit, which the outline and the + // shadows follow. + var textScale = 1.0; TextPainter createPainter(TextStyle Function(TextStyle style)? restyle) { var content = restyle == null ? text : _restyleSpan(text, restyle); @@ -229,7 +231,9 @@ class RoundedBackgroundText extends StatelessWidget { textAlign: align, textWidthBasis: defaultTextStyle.textWidthBasis, textHeightBehavior: defaultTextStyle.textHeightBehavior, - textScaler: textScaler, + textScaler: textScale == 1 + ? TextScaler.noScaling + : TextScaler.linear(textScale), ); } @@ -244,7 +248,7 @@ class RoundedBackgroundText extends StatelessWidget { Paint strokePaint(Color color) => Paint() ..style = PaintingStyle.stroke // Half of the stroke lies inside the glyph, under the fill. - ..strokeWidth = outlineWidth * 2 + ..strokeWidth = outlineWidth * 2 * textScale ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round ..color = color; @@ -258,15 +262,20 @@ class RoundedBackgroundText extends StatelessWidget { final box = this.box; if (box != null && box.overflow == TextLayerBoxOverflow.shrink) { - textScaler = TextScaler.linear( - _shrinkFactor(createPainter(null), box.size, effectSpace), - ); + textScale = _shrinkFactor(createPainter(null), box.size, effectSpace); } + List? scaleShadows(List? shadows) => textScale == 1 + ? shadows + : shadows?.map((shadow) => shadow.scale(textScale)).toList(); // With an outline, the shadows are cast by the outlined glyphs, which the // painter draws from opaque silhouettes; the passes themselves draw none. final painter = createPainter( - hasOutline ? (style) => style.copyWith(shadows: const []) : null, + hasOutline + ? (style) => style.copyWith(shadows: const []) + : textScale == 1 + ? null + : (style) => style.copyWith(shadows: scaleShadows(style.shadows)), ); final outlinePainter = hasOutline ? createPainter(paintWith(strokePaint(outlineColor))) @@ -295,7 +304,9 @@ class RoundedBackgroundText extends StatelessWidget { outlineWidth: hasOutline ? outlineWidth : 0, outlineColor: outlineColor, silhouettePainters: silhouettePainters, - silhouetteShadows: hasOutlineShadows ? shadows : const [], + silhouetteShadows: hasOutlineShadows + ? scaleShadows(shadows)! + : const [], onHitTestResult: onHitTestResult, textAlign: align, cursorWidth: cursorWidth, diff --git a/pubspec.yaml b/pubspec.yaml index 9e5c04f57..5ecef86e9 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.14.3 +version: 14.15.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/text_layer_box_test.dart b/test/core/models/layers/text_layer_box_test.dart index e00b38198..4be806a03 100644 --- a/test/core/models/layers/text_layer_box_test.dart +++ b/test/core/models/layers/text_layer_box_test.dart @@ -39,6 +39,24 @@ void main() { expect(restored.verticalAlign, TextLayerBoxVerticalAlign.top); expect(restored.overflow, TextLayerBoxOverflow.clip); }); + + test('reads a size written as text', () { + final restored = TextLayerBox.fromMap(const { + 'width': '120.5', + 'height': '40', + }); + + expect(restored.size, const Size(120.5, 40)); + }); + + test('rounds its size to the decimal places asked for', () { + const exact = TextLayerBox(size: Size(144.30656934306566, 40.004)); + + final map = exact.toMap(maxDecimalPlaces: 2); + + expect(map['width'], 144.31); + expect(map['height'], 40); + }); }); group('TextLayerBox.resize', () { @@ -187,6 +205,18 @@ void main() { ); }); + test('toMapFromReference rounds the box like the other values', () { + final reference = boxedLayer(); + final resized = reference.copyWith( + box: box.copyWith(size: const Size(144.30656934306566, 80)), + ); + + final map = resized.toMapFromReference(reference, maxDecimalPlaces: 2); + + expect(map['box']['width'], 144.31); + expect(resized.toMap(maxDecimalPlaces: 2)['box']['width'], 144.31); + }); + test('toMapFromReference writes null when the box was removed', () { final reference = boxedLayer(); final removed = reference.copyWith()..box = null; diff --git a/test/features/main_editor/services/text_box_resize_test.dart b/test/features/main_editor/services/text_box_resize_test.dart index c085af71c..9bd28b52c 100644 --- a/test/features/main_editor/services/text_box_resize_test.dart +++ b/test/features/main_editor/services/text_box_resize_test.dart @@ -94,6 +94,21 @@ void main() { expect(layer.box!.size, const Size(100, 40)); }); + test('starts from where the copy is shown at the first update', () { + final layer = boxedLayer(); + final interactions = manager() + ..startTextBoxResize(layer, TextLayerBoxHandle.left); + // Keyframes lay the copy out where they show it once the gesture runs. + final copy = layer.copyWith(offset: const Offset(50, 60)); + shown = [copy]; + + drag(interactions, Offset.zero); + drag(interactions, const Offset(-20, 0)); + + expect(copy.box!.size, const Size(110, 40)); + expect(copy.offset, const Offset(30, 60)); + }); + test('measures from where the handle was pressed', () { final layer = boxedLayer(); final interactions = manager() diff --git a/test/features/main_editor/text_box_resize_editor_test.dart b/test/features/main_editor/text_box_resize_editor_test.dart index bd6863d4f..7b47a8573 100644 --- a/test/features/main_editor/text_box_resize_editor_test.dart +++ b/test/features/main_editor/text_box_resize_editor_test.dart @@ -102,6 +102,92 @@ void main() { // The edge follows the whole drag: 40 px, at a scale of 1. expect(live.box!.size.width, closeTo(160, 0.001)); expect(live.box!.size.height, 40); + + // The drag was one history step. + state.undoAction(); + await tester.pumpAndSettle(); + expect( + (state.activeLayers.single as TextLayer).box!.size, + const Size(120, 40), + ); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('a box handle keeps the opposite edge of a layer with ' + 'keyframes where they show it', (tester) async { + final key = GlobalKey(); + const videoKey = ValueKey('video'); + final controller = ProVideoController( + videoPlayer: const SizedBox.expand(key: videoKey), + videoDuration: const Duration(seconds: 10), + initialResolution: const Size(108, 192), + fileSize: 1, + ); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.video( + controller, + key: key, + configs: configs, + callbacks: const ProImageEditorCallbacks(), + ), + ), + ); + // The video canvas waits for a real image encode and decode, which fake + // async never completes. + await tester.runAsync(() async { + for (var i = 0; i < 100 && find.byKey(videoKey).evaluate().isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + }); + final state = key.currentState!; + final layer = TextLayer( + text: 'Name', + scale: 1, + startTime: Duration.zero, + endTime: const Duration(seconds: 10), + box: const TextLayerBox(size: Size(120, 40)), + keyframes: const [ + LayerKeyframe(time: Duration.zero, offset: Offset(-30, 0)), + LayerKeyframe(time: Duration(seconds: 4), offset: Offset(30, 0)), + ], + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + // Half way between the keyframes, which show the layer at the center. + controller.setPlayTime(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 1)); + state.selectLayerById(layer.id); + // The overlay shows after the frame that selects the layer. + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + await tester.pump(); + final before = tester.getRect(find.byType(RoundedBackgroundText)); + + final gesture = await tester.startGesture( + Offset(before.right, before.center.dy), + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton, + ); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + final after = tester.getRect(find.byType(RoundedBackgroundText)); + final live = state.activeLayers.single as TextLayer; + expect(live.box!.size.width, closeTo(160, 0.001)); + expect(after.left, closeTo(before.left, 0.001)); + expect( + live.keyframes.map((keyframe) => keyframe.time), + contains(const Duration(seconds: 2)), + ); }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); group('a held box handle', () { diff --git a/test/features/text_editor/rounded_background_text_box_test.dart b/test/features/text_editor/rounded_background_text_box_test.dart index fd53b8867..80eb0fab0 100644 --- a/test/features/text_editor/rounded_background_text_box_test.dart +++ b/test/features/text_editor/rounded_background_text_box_test.dart @@ -134,6 +134,38 @@ void main() { expect(painter.painter.height, lessThanOrEqualTo(60 - padding * 2)); }); + for (final outlineWidth in [0.0, 4.0]) { + testWidgets('shrinks the shadows and an outline of $outlineWidth with ' + 'the text', (tester) async { + const shadow = Shadow(offset: Offset(4, 4), blurRadius: 2); + await pumpText( + tester, + RoundedBackgroundText( + 'one two three four five six seven eight', + style: style.copyWith(shadows: const [shadow]), + maxTextWidth: double.infinity, + enableHitBoxCorrection: true, + outlineWidth: outlineWidth, + box: const TextLayerBox( + size: Size(120, 60), + overflow: TextLayerBoxOverflow.shrink, + ), + ), + ); + + final painter = textPainter(tester); + final factor = painter.painter.textScaler.scale(1); + expect(factor, lessThan(1)); + if (outlineWidth == 0) { + expect(painter.painter.text!.style!.shadows, [shadow.scale(factor)]); + } else { + final outline = painter.outlinePainter!.text!.style!.foreground!; + expect(outline.strokeWidth, closeTo(outlineWidth * 2 * factor, 1e-9)); + expect(painter.silhouetteShadows, [shadow.scale(factor)]); + } + }); + } + testWidgets('clips text taller than the box by default', (tester) async { await pumpText( tester,