diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart index fd6a1728f6..c9e10c359d 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart @@ -136,10 +136,10 @@ class _MobileEditingIOSDemoState extends State with Single child: IOSFollowingMagnifier.roundedRectangle( magnifierKey: magnifierKey, leaderLink: focalPoint, - // The bottom of the magnifier sits above the focal point. - // Leave a few pixels between the bottom of the magnifier and the focal point. This - // value was chosen empirically. - offsetFromFocalPoint: const Offset(0, -20), + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), show: isVisible, ), ); diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index 7774221be5..7dfd9590a4 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -1948,23 +1948,20 @@ class SuperEditorAndroidControlsOverlayManagerState extends State with Sing // Animate the magnfier up on entrance and down on exit. widget.offsetFromFocalPoint.dy * devicePixelRatio * percentage, ), - // Translate the magnifier so it's displayed above the focal point - // when the animation ends. - child: FractionalTranslation( - translation: Offset(0.0, -0.5 * percentage), - child: widget.magnifierBuilder( - context, - IosMagnifierViewModel( - // In theory, the offsetFromFocalPoint should either be `widget.offsetFromFocalPoint.dy` to match - // the actual offset, or it should be `widget.offsetFromFocalPoint.dy / magnificationLevel`. Neither - // of those align the focal point correctly. The following offset was found empirically to give the - // desired results. These values seem to work even with different pixel densities. - offsetFromFocalPoint: Offset( - -22 * percentage, - (-defaultIosMagnifierSize.height + 14) * percentage, - ), - animationValue: _animationController.value, - animationDirection: - const [AnimationStatus.forward, AnimationStatus.completed].contains(_animationController.status) - ? AnimationDirection.forward - : AnimationDirection.reverse, - borderColor: widget.handleColor ?? Theme.of(context).primaryColor, + boundary: ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + child: widget.magnifierBuilder( + context, + IosMagnifierViewModel( + offsetFromFocalPoint: Offset( + widget.offsetFromFocalPoint.dx * percentage, + widget.offsetFromFocalPoint.dy * percentage, ), - widget.magnifierKey, + animationValue: _animationController.value, + animationDirection: + const [AnimationStatus.forward, AnimationStatus.completed].contains(_animationController.status) + ? AnimationDirection.forward + : AnimationDirection.reverse, + borderColor: widget.handleColor ?? Theme.of(context).primaryColor, ), + widget.magnifierKey, ), ); }, diff --git a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart index 95fafe93d4..db7d3e70a2 100644 --- a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart +++ b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart @@ -370,7 +370,7 @@ class GestureEditingController with ChangeNotifier { GestureEditingController({ required this.selectionLinks, required MagnifierAndToolbarController overlayController, - required LayerLink magnifierFocalPointLink, + required LeaderLink magnifierFocalPointLink, }) : _magnifierFocalPointLink = magnifierFocalPointLink, _overlayController = overlayController { _overlayController.addListener(_toolbarChanged); @@ -386,8 +386,8 @@ class GestureEditingController with ChangeNotifier { /// A `LayerLink` whose top-left corner sits at the location where the /// magnifier should magnify. - LayerLink get magnifierFocalPointLink => _magnifierFocalPointLink; - final LayerLink _magnifierFocalPointLink; + LeaderLink get magnifierFocalPointLink => _magnifierFocalPointLink; + final LeaderLink _magnifierFocalPointLink; /// Controls the magnifier and the toolbar. MagnifierAndToolbarController get overlayController => _overlayController; diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index 1c0438ec13..e9efeed297 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -132,7 +132,7 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _magnifierFocalPoint; + final LeaderLink _magnifierFocalPoint; + LeaderLink get magnifierFocalPoint => _magnifierFocalPoint; bool _isMagnifierVisible = false; bool get isMagnifierVisible => _isMagnifierVisible; diff --git a/super_editor/lib/src/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/super_textfield/android/_user_interaction.dart index 402601c016..d6758e4cbb 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; @@ -691,7 +692,7 @@ class AndroidTextFieldTouchInteractorState extends State late ImeAttributedTextEditingController _textEditingController; - final _magnifierLayerLink = LayerLink(); + final _magnifierLayerLink = LeaderLink(); late AndroidEditingOverlayController _editingOverlayController; late TextScrollController _textScrollController; diff --git a/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart b/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart index 8eeac0bf38..03c79cc00e 100644 --- a/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart @@ -57,10 +57,57 @@ class MagnifyingGlass extends StatelessWidget { } ImageFilter _createMagnificationFilter() { + // When displayed without scaling, the content inside the magnifier looks + // like this: + // ________________ + // | | + // | center | + // |________________| + // + // Applying scaling causes the content to grow outward shifting the center + // away from the magnifier's center, like this: + // ________________ + // | | + // | | + // |_________c e n t|e r + // + // To correct this, we shift the content in the opposite direction before scaling, + // so it appears like this before scaling: + // ________________ + // |center | + // | | + // |________________| + // + // After scaling, the content shifts again due to the scaling effect. However, + // the pre-shift ensures that the center of the content aligns correctly within + // the magnifier, like this: + // ________________ + // | | + // | c e n t e r | + // |________________| + // final magnifierMatrix = Matrix4.identity() - ..translate(offsetFromFocalPoint.dx * magnificationScale, offsetFromFocalPoint.dy * magnificationScale) - ..scale(magnificationScale, magnificationScale); - + // Calculate the extra size introduced by scaling and move the content + // back by half of that amount. + // + // For example: + // + // If the magnifier is 133px wide with a magnification scale of 1.5, + // the scaled width will be: + // 133px * 1.5 = 199.5px. + // + // The width increases by 66.5px in total. Since the growth is symmetric, + // we shift the content left by half the increase (66.5px / 2 = 33.25px) + // to re-center it under the magnifier after the scaling. + ..translate( + -(size.width * magnificationScale - size.width) / 2, + -(size.height * magnificationScale - size.height) / 2, + ) + // Apply the scaling transformation to magnify the content. + ..scale(magnificationScale, magnificationScale) + // Move the content to the center of where the app wants to + // display the magnifier. + ..translate(offsetFromFocalPoint.dx, offsetFromFocalPoint.dy); return ImageFilter.matrix(magnifierMatrix.storage); } } diff --git a/super_editor/lib/src/super_textfield/ios/editing_controls.dart b/super_editor/lib/src/super_textfield/ios/editing_controls.dart index 411f70a9ab..28841f3332 100644 --- a/super_editor/lib/src/super_textfield/ios/editing_controls.dart +++ b/super_editor/lib/src/super_textfield/ios/editing_controls.dart @@ -570,10 +570,10 @@ class _IOSEditingControlsState extends State return IOSFollowingMagnifier.roundedRectangle( leaderLink: widget.editingController.magnifierFocalPoint, show: showMagnifier, - // The bottom of the magnifier sits above the focal point. - // Leave a few pixels between the bottom of the magnifier and the focal point. This - // value was chosen empirically. - offsetFromFocalPoint: const Offset(0, -20), + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), ); }, ); diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png new file mode 100644 index 0000000000..9cc0840179 Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png new file mode 100644 index 0000000000..28e59d5c4b Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart b/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart new file mode 100644 index 0000000000..7ee71d86df --- /dev/null +++ b/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../../test/super_editor/supereditor_test_tools.dart'; +import '../../test_tools_goldens.dart'; + +void main() { + group("SuperEditor > Android > overlay controls >", () { + testGoldensOnAndroid("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(Stylesheet( + rules: defaultStylesheet.rules, + inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + )) + .pump(); + + // Place the caret at "Duis aute|" (line 6). + await tester.tapInParagraph("1", 241); + + // // Press and drag the caret to the beginning of the line. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 1; i < 7; i++) { + await gesture.moveBy(const Offset(-12, 0)); + await tester.pump(); + } + + await tester.pump(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/supereditor_android_magnifier_screen_edges.png", 52), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +} + +TextStyle _textStyleBuilder(Set attributions) { + return const TextStyle( + color: Colors.black, + fontFamily: 'Roboto', + fontSize: 16, + height: 1.4, + ); +} diff --git a/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart b/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..b3f0f8e07d --- /dev/null +++ b/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../../test/super_editor/supereditor_test_tools.dart'; +import '../../test_tools_goldens.dart'; + +void main() { + group("SuperEditor > iOS > overlay controls >", () { + testGoldensOniOS("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(Stylesheet( + rules: defaultStylesheet.rules, + inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + )) + .pump(); + + // Place the caret at "Duis aute|" (line 6). + await tester.tapInParagraph("1", 241); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.tapDownInParagraph("1", 241); + for (int i = 1; i < 7; i++) { + await gesture.moveBy(const Offset(-12, 0)); + await tester.pump(); + } + + await screenMatchesGolden(tester, 'supereditor_ios_magnifier_screen_edges'); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + }); + }); +} + +TextStyle _textStyleBuilder(Set attributions) { + return const TextStyle( + color: Colors.black, + fontFamily: 'Roboto', + fontSize: 16, + height: 1.4, + ); +} diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png new file mode 100644 index 0000000000..cf998e6254 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png new file mode 100644 index 0000000000..8ae6ab59c3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart new file mode 100644 index 0000000000..3af0918dac --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart @@ -0,0 +1,61 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_text_field.dart'; + +import '../../test/super_textfield/super_textfield_robot.dart'; +import '../test_tools_goldens.dart'; + +void main() { + group("SuperTextField > Android > overlay controls >", () { + testGoldensOnAndroid("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final controller = AttributedTextEditingController( + text: AttributedText('Lorem ipsum dolor sit amet'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: double.infinity, + child: SuperTextField( + textController: controller, + padding: const EdgeInsets.all(20), + textStyleBuilder: (_) => const TextStyle( + color: Colors.black, + // Use Roboto so that goldens show real text + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + debugShowCheckedModeBanner: false, + ), + ); + + // Place the caret at the end of the textfield. + await tester.placeCaretInSuperTextField(30); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-200, 0)); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_android_magnifier_screen_edges.png", 38), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..d9ac59463a --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../../test/super_textfield/super_textfield_robot.dart'; +import '../test_tools_goldens.dart'; + +void main() { + group("SuperTextField > iOS > overlay controls >", () { + testGoldensOniOS("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final controller = AttributedTextEditingController( + text: AttributedText('Lorem ipsum dolor sit amet'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: double.infinity, + child: SuperTextField( + textController: controller, + padding: const EdgeInsets.all(20), + textStyleBuilder: (_) => const TextStyle( + color: Colors.black, + // Use Roboto so that goldens show real text + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + debugShowCheckedModeBanner: false, + ), + ); + + // Place the caret at the end of the textfield. + await tester.placeCaretInSuperTextField(30); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-200, 0)); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_magnifier_screen_edges.png", 4), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +}