diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index e5c9c9186..eba8db3fb 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -80,32 +80,26 @@ class _PlacedImageBuilderState extends State { pinnedScreenPosition = nextPinned; } - /// Stores the new scale, and the position that keeps the image's top-left - /// where the resize pinned it. On defense the image hangs from its - /// bottom-right corner, so that position moves with the size. + /// Stores the new scale at once, so a page save right after release keeps + /// it, with the position that keeps the image's top-left where the resize + /// pinned it. On defense the image hangs from its bottom-right corner, so + /// that position moves with the size. /// - /// Waits for the frame that lays out the last drag update, so the stored - /// position matches the size the user let go at. + /// The last drag update may not be laid out yet, so the pin holds for one + /// more frame, then the position is stored again if it moved, unless a + /// page switch loaded another page's copy of the image, which keeps its id. Future _finishResize(WidgetRef ref) async { + final resizedImage = widget.placedImage; + final pinned = pinnedScreenPosition; + if (pinned != null) _storeResize(ref, resizedImage, pinned); await WidgetsBinding.instance.endOfFrame; if (!mounted) return; - final pinned = pinnedScreenPosition; - final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; - if (pinned != null && renderBox != null) { - final coordinateSystem = CoordinateSystem.instance; - final position = coordinateSystem.screenToCoordinate( - coordinateSystem.screenPositionFromSide( - sideScreenPosition: pinned, - reflectionOffset: renderBox.size.bottomRight(Offset.zero), - isAttack: widget.isAttack, - ), - ); - ref - .read(placedImageProvider.notifier) - .resize(widget.placedImage.id, scale: localScale!, position: position); - ref.read(strategyProvider.notifier).setUnsaved(); - } + final stillLoaded = ref + .read(placedImageProvider) + .images + .any((image) => identical(image, resizedImage)); + if (pinned != null && stillLoaded) _storeResize(ref, resizedImage, pinned); setState(() { isPanning = false; pinnedScreenPosition = null; @@ -113,6 +107,27 @@ class _PlacedImageBuilderState extends State { }); } + /// Writes [image]'s new scale and the position that puts its top-left at + /// [pinned] as laid out now, unless both are already stored. + void _storeResize(WidgetRef ref, PlacedImage image, Offset pinned) { + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + if (image.scale == localScale && image.position == position) return; + ref + .read(placedImageProvider.notifier) + .resize(image.id, scale: localScale!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + @override Widget build(BuildContext context) { if (localScale == null) { diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index ad80fbc11..4dad542a0 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -85,32 +85,25 @@ class _PlacedTextBuilderState extends ConsumerState { pinnedScreenPosition = nextPinned; } - /// Stores the new width, and the position that keeps the box's top-left - /// where the resize pinned it. On defense the box hangs from its - /// bottom-right corner, so that position moves with the size. + /// Stores the new width at once, so a page save right after release keeps + /// it, with the position that keeps the box's top-left where the resize + /// pinned it. On defense the box hangs from its bottom-right corner, so + /// that position moves with the size. /// - /// Waits for the frame that lays out the last drag update, because the - /// text's height, and so its defense position, follows from its width. + /// The text's height, and so its defense position, follows from its width, + /// and the last drag update may not be laid out yet. So the pin holds for + /// one more frame, then the position is stored again if it moved, unless a + /// page switch loaded another page's copy of the text, which keeps its id. Future _finishResize() async { + final resizedText = widget.placedText; + final pinned = pinnedScreenPosition; + if (pinned != null) _storeResize(resizedText, pinned); await WidgetsBinding.instance.endOfFrame; if (!mounted) return; - final pinned = pinnedScreenPosition; - final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; - if (pinned != null && renderBox != null) { - final coordinateSystem = CoordinateSystem.instance; - final position = coordinateSystem.screenToCoordinate( - coordinateSystem.screenPositionFromSide( - sideScreenPosition: pinned, - reflectionOffset: renderBox.size.bottomRight(Offset.zero), - isAttack: widget.isAttack, - ), - ); - ref - .read(textProvider.notifier) - .resize(widget.placedText.id, size: localSize!, position: position); - ref.read(strategyProvider.notifier).setUnsaved(); - } + final stillLoaded = + ref.read(textProvider).any((text) => identical(text, resizedText)); + if (pinned != null && stillLoaded) _storeResize(resizedText, pinned); setState(() { isPanning = false; isDragging = false; @@ -119,6 +112,27 @@ class _PlacedTextBuilderState extends ConsumerState { }); } + /// Writes [text]'s new width and the position that puts its top-left at + /// [pinned] as laid out now, unless both are already stored. + void _storeResize(PlacedText text, Offset pinned) { + final renderBox = _boxKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final coordinateSystem = CoordinateSystem.instance; + final position = coordinateSystem.screenToCoordinate( + coordinateSystem.screenPositionFromSide( + sideScreenPosition: pinned, + reflectionOffset: renderBox.size.bottomRight(Offset.zero), + isAttack: widget.isAttack, + ), + ); + if (text.size == localSize && text.position == position) return; + ref + .read(textProvider.notifier) + .resize(text.id, size: localSize!, position: position); + ref.read(strategyProvider.notifier).setUnsaved(); + } + @override Widget build(BuildContext context) { final texts = ref.watch(textProvider); diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 6fe61084f..02bedea6e 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -227,4 +227,106 @@ void main() { tester.getRect(box).topLeft, offsetMoreOrLessEquals(onDefense.topLeft)); expect(container.read(textProvider).single.size, greaterThan(200)); }); + + testWidgets('a release before the next frame keeps its last movement', + (tester) async { + await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: ValueNotifier(false), + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final box = find.byType(TextScaleController); + final handle = find.byWidgetPredicate( + (widget) => + widget is MouseRegion && + widget.cursor == SystemMouseCursors.resizeLeftRight, + ); + + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var i = 0; i < 3; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + final shown = tester.getRect(box); + + // A last move and the release arrive before the next frame draws it. + await gesture.moveBy(const Offset(40, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.getRect(box).topLeft, offsetMoreOrLessEquals(shown.topLeft)); + expect(tester.getRect(box).width, closeTo(shown.width + 40, 1)); + }); + + testWidgets('a page switch right after release keeps the resize on its page', + (tester) async { + final container = await pumpBox( + tester, + seed: (container) => container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: originalPosition, + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now', + ]), + side: ValueNotifier(false), + builder: (ref, isAttack) { + final placedText = ref.watch(textProvider).single; + return PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (_) {}, + ); + }, + ); + final handle = find.byWidgetPredicate( + (widget) => + widget is MouseRegion && + widget.cursor == SystemMouseCursors.resizeLeftRight, + ); + + final gesture = await tester.startGesture(tester.getCenter(handle)); + for (var i = 0; i < 3; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + final resized = container.read(textProvider).single; + await gesture.up(); + + // Before the frame, the page is saved and the next page loads its copy + // of the text. The save must already see the new width. + expect(resized.size, greaterThan(200)); + final copy = PlacedText( + id: 'text-1', + position: const Offset(100, 100), + size: 200, + sizeVersion: PlacedText.currentSizeVersion, + )..text = 'Yo text boxes resize properly on both sides now'; + container.read(textProvider.notifier).fromHive([copy]); + await tester.pumpAndSettle(); + + final loaded = container.read(textProvider).single; + expect(loaded.size, 200); + expect(loaded.position, const Offset(100, 100)); + }); }