diff --git a/super_editor/example/lib/demos/supertextfield/demo_textfield_with_context_menu.dart b/super_editor/example/lib/demos/supertextfield/demo_textfield_with_context_menu.dart new file mode 100644 index 000000000..859f6abb0 --- /dev/null +++ b/super_editor/example/lib/demos/supertextfield/demo_textfield_with_context_menu.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Demo of [SuperTextField] with a context menu that opens in response +/// to various gestures on desktop. +class TextFieldWithContextMenuDemo extends StatefulWidget { + @override + _TextFieldWithContextMenuDemoState createState() => _TextFieldWithContextMenuDemoState(); +} + +class _TextFieldWithContextMenuDemoState extends State { + GlobalKey? _textFieldKey; + late final _contextMenuOverlay; + + KeyMessageHandler? _existingKeyMessageHandler; + bool _isCtrlPressed = false; + + @override + void initState() { + super.initState(); + _contextMenuOverlay = _ContextMenuOverlay(); + + _existingKeyMessageHandler = ServicesBinding.instance.keyEventManager.keyMessageHandler; + ServicesBinding.instance.keyEventManager.keyMessageHandler = _handleGlobalKeyMessage; + } + + @override + void dispose() { + ServicesBinding.instance.keyEventManager.keyMessageHandler = _existingKeyMessageHandler; + + _contextMenuOverlay.hide(); + super.dispose(); + } + + bool _handleGlobalKeyMessage(KeyMessage keyMessage) { + for (final event in keyMessage.events) { + if (event.logicalKey.synonyms.contains(LogicalKeyboardKey.control)) { + if (event is KeyDownEvent) { + setState(() { + _isCtrlPressed = true; + }); + } else if (event is KeyUpEvent) { + setState(() { + _isCtrlPressed = false; + }); + } + } + } + + return _existingKeyMessageHandler?.call(keyMessage) ?? false; + } + + void _onTapDown(TapDownDetails details) { + if (_isCtrlPressed && defaultTargetPlatform == TargetPlatform.macOS) { + _contextMenuOverlay.show(context, details.globalPosition); + } + } + + void _onRightTapDown(TapDownDetails details) { + if (defaultTargetPlatform == TargetPlatform.macOS) { + _contextMenuOverlay.show(context, details.globalPosition); + + // This shows you how you would calculate the text position for + // a gesture override. + final textFieldBox = _textFieldKey!.currentContext!.findRenderObject() as RenderBox; + final textLayout = (_textFieldKey!.currentState as ProseTextBlock).textLayout; + final textPosition = textLayout.getPositionNearestToOffset( + textFieldBox.globalToLocal(details.globalPosition), + ); + print("You right-clicked near text position: $textPosition"); + } + } + + void _onRightTapUp(TapUpDetails details) { + if (defaultTargetPlatform == TargetPlatform.windows) { + _contextMenuOverlay.show(context, details.globalPosition); + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTextField(), + const SizedBox(height: 24), + _buildDescription(), + ], + ), + ), + ); + } + + Widget _buildTextField() { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + hintBuilder: (context) { + return Text( + "enter text here...", + style: const TextStyle(color: Colors.grey), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 18, + ); + }, + gestureOverrideBuilder: _gestureOverrideBuilder, + ), + ); + } + + Widget _gestureOverrideBuilder(BuildContext context, textFieldGlobalKey, [Widget? child]) { + _textFieldKey = textFieldGlobalKey; + return GestureDetector( + onTapDown: _onTapDown, + onSecondaryTapDown: _onRightTapDown, + onSecondaryTapUp: _onRightTapUp, + behavior: HitTestBehavior.translucent, + child: child, + ); + } + + Widget _buildDescription() { + return const Text( + "This SuperTextField includes gesture overrides:\n" + " • Right tap down on Mac to open a context menu\n" + " • Right tap up on Windows to open a context menu\n" + " • Control + Left tap down on Mac to open a context menu", + style: TextStyle( + color: Colors.grey, + fontSize: 14, + height: 1.8, + ), + ); + } +} + +class _ContextMenuOverlay { + OverlayEntry? _entry; + late Offset _globalOffset; + + void show(BuildContext context, Offset globalOffset) { + _globalOffset = globalOffset; + + if (_entry == null) { + _entry = OverlayEntry(builder: (innerContext) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => hide(), + child: Container( + color: Colors.black.withOpacity(0.1), + ), + ), + ), + Positioned( + left: _globalOffset.dx, + top: _globalOffset.dy, + child: Container( + width: 200, + height: 125, + color: Colors.grey.shade800, + ), + ), + ], + ); + }); + + Overlay.of(context)!.insert(_entry!); + } else { + _entry!.markNeedsBuild(); + } + } + + void hide() { + _entry?.remove(); + _entry = null; + } +} diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 908690576..68047ceb7 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -30,6 +30,7 @@ import 'demos/demo_switch_document_content.dart'; import 'demos/scrolling/demo_task_and_chat_with_renderobject.dart'; import 'demos/super_document/demo_read_only_scrolling_document.dart'; import 'demos/supertextfield/android/demo_superandroidtextfield.dart'; +import 'demos/supertextfield/demo_textfield_with_context_menu.dart'; /// Demo of a basic text editor, as well as various widgets that /// are available in this package. @@ -326,6 +327,13 @@ final _menu = <_MenuGroup>[ return TextFieldDemo(); }, ), + _MenuItem( + icon: Icons.text_fields, + title: 'SuperTextField with context menu', + pageBuilder: (context) { + return TextFieldWithContextMenuDemo(); + }, + ), _MenuItem( icon: Icons.text_fields, title: 'Super iOS Textfield', diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart index 47e980b56..cdc13bebd 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart @@ -2,7 +2,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/gesture_overrides.dart'; +import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart'; +import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '_editing_controls.dart'; @@ -46,6 +48,7 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { required this.textKey, required this.isMultiline, required this.handleColor, + this.gestureOverrideBuilder, this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -84,6 +87,9 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// The color of expanded selection drag handles. final Color handleColor; + /// {@macros SuperTextField_gestureOverrideBuilder} + final GestureOverrideBuilder? gestureOverrideBuilder; + /// Whether to paint debugging guides and regions. final bool showDebugPaint; @@ -437,6 +443,7 @@ class AndroidTextFieldTouchInteractorState extends State keyboardHandlers; + /// {@macros SuperTextField_gestureOverrideBuilder} + final GestureOverrideBuilder? gestureOverrideBuilder; + @override SuperDesktopTextFieldState createState() => SuperDesktopTextFieldState(); } @@ -280,6 +286,7 @@ class SuperDesktopTextFieldState extends State implements textScrollKey: _textScrollKey, isMultiline: isMultiline, onRightClick: widget.onRightClick, + gestureOverrideBuilder: widget.gestureOverrideBuilder, child: MultiListenableBuilder( listenables: { _focusNode, @@ -360,6 +367,7 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { required this.textScrollKey, required this.isMultiline, this.onRightClick, + this.gestureOverrideBuilder, required this.child, }) : super(key: key); @@ -382,8 +390,20 @@ class SuperTextFieldGestureInteractor extends StatefulWidget { final bool isMultiline; /// Callback invoked when the user right clicks on this text field. + @Deprecated("Use gestureOverrideBuilder to take control of desired gestures") final RightClickListener? onRightClick; + /// {@template SuperTextField_gestureOverrideBuilder} + /// Widget that takes up as much space as the text field, and can be used to override + /// default text field gestures, so that clients can handle some of them instead. + /// + /// For example, a client may want to open a context menu on right-click, + /// or on ALT + left-click. Rather than add all possible gesture controls to this + /// text field, clients can pass a [gestureOverrideBuilder] to respond to any + /// set of desired gestures. + /// {@endtemplate} + final GestureOverrideBuilder? gestureOverrideBuilder; + /// The rest of the subtree for this text field. final Widget child; @@ -433,10 +453,9 @@ class _SuperTextFieldGestureInteractorState extends State{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onDoubleTapDown = _onDoubleTapDown - ..onDoubleTap = _onDoubleTap - ..onTripleTapDown = _onTripleTapDown - ..onTripleTap = _onTripleTap; - }, - ), - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; - }, - ), - }, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (TapGestureRecognizer recognizer) { + recognizer.onSecondaryTapUp = _onRightTapUp; + }, + ), + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapDown = _onTripleTapDown + ..onTripleTapUp = _onTripleTapUp; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel; + }, + ), + }, + child: _buildGestureOverrides( child: MouseRegion( cursor: SystemMouseCursors.text, child: widget.child, @@ -750,6 +777,14 @@ class _SuperTextFieldGestureInteractorState extends State selectableTextKey; + final GlobalKey textKey; /// Whether the text field that owns this [IOSTextFieldInteractor] is /// a multiline text field. @@ -80,6 +83,9 @@ class IOSTextFieldTouchInteractor extends StatefulWidget { /// The color of expanded selection drag handles. final Color handleColor; + /// {@macros SuperTextField_gestureOverrideBuilder} + final GestureOverrideBuilder? gestureOverrideBuilder; + /// Whether to paint debugging guides and regions. final bool showDebugPaint; @@ -123,7 +129,7 @@ class IOSTextFieldTouchInteractorState extends State widget.selectableTextKey.currentState!.textLayout; + ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; void _onTapDown(TapDownDetails details) { _log.fine("User tapped down"); @@ -299,7 +305,7 @@ class IOSTextFieldTouchInteractorState extends State link: _textFieldLayerLink, child: IOSTextFieldTouchInteractor( focusNode: _focusNode, - selectableTextKey: _textContentKey, + textKey: _textContentKey, textFieldLayerLink: _textFieldLayerLink, textController: _textEditingController, editingOverlayController: _editingOverlayController, diff --git a/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart b/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart index b9fa7d101..6e41475a4 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart @@ -6,6 +6,7 @@ import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/super_textfield/android/android_textfield.dart'; import 'package:super_editor/src/infrastructure/super_textfield/desktop/desktop_textfield.dart'; import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/gesture_overrides.dart'; import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart'; import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; import 'package:super_editor/src/infrastructure/super_textfield/ios/ios_textfield.dart'; @@ -16,6 +17,7 @@ import 'styles.dart'; export 'android/android_textfield.dart'; export 'desktop/desktop_textfield.dart'; export 'infrastructure/attributed_text_editing_controller.dart'; +export 'infrastructure/gesture_overrides.dart'; export 'infrastructure/hint_text.dart'; export 'infrastructure/magnifier.dart'; export 'infrastructure/text_scrollview.dart'; @@ -63,6 +65,7 @@ class SuperTextField extends StatefulWidget { this.maxLines = 1, this.lineHeight, this.keyboardHandlers = defaultTextFieldKeyboardHandlers, + this.gestureOverrideBuilder, }) : super(key: key); final FocusNode? focusNode; @@ -141,11 +144,14 @@ class SuperTextField extends StatefulWidget { /// Only used on desktop. final List keyboardHandlers; + /// {@macros SuperTextField_gestureOverrideBuilder} + final GestureOverrideBuilder? gestureOverrideBuilder; + @override State createState() => SuperTextFieldState(); } -class SuperTextFieldState extends State { +class SuperTextFieldState extends State implements ProseTextBlock { final _platformFieldKey = GlobalKey(); late ImeAttributedTextEditingController _controller; @@ -176,7 +182,7 @@ class SuperTextFieldState extends State { @visibleForTesting AttributedTextEditingController get controller => _controller; - @visibleForTesting + @override ProseTextLayout get textLayout => (_platformFieldKey.currentState as ProseTextBlock).textLayout; bool get _isMultiline => (widget.minLines ?? 1) != 1 || (widget.maxLines ?? 1) != 1; @@ -238,6 +244,7 @@ class SuperTextFieldState extends State { minLines: widget.minLines, maxLines: widget.maxLines, keyboardHandlers: widget.keyboardHandlers, + gestureOverrideBuilder: widget.gestureOverrideBuilder, ); case SuperTextFieldPlatformConfiguration.android: return Shortcuts( @@ -257,6 +264,7 @@ class SuperTextFieldState extends State { maxLines: widget.maxLines, lineHeight: widget.lineHeight, textInputAction: _isMultiline ? TextInputAction.newline : TextInputAction.done, + gestureOverrideBuilder: widget.gestureOverrideBuilder, ), ); case SuperTextFieldPlatformConfiguration.iOS: @@ -277,6 +285,7 @@ class SuperTextFieldState extends State { maxLines: widget.maxLines, lineHeight: widget.lineHeight, textInputAction: _isMultiline ? TextInputAction.newline : TextInputAction.done, + gestureOverrideBuilder: widget.gestureOverrideBuilder, ), ); } diff --git a/super_editor/test/super_textfield/super_desktop_textfield_gesture_extensions_test.dart b/super_editor/test/super_textfield/super_desktop_textfield_gesture_extensions_test.dart new file mode 100644 index 000000000..4e1d477e0 --- /dev/null +++ b/super_editor/test/super_textfield/super_desktop_textfield_gesture_extensions_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../test_tools.dart'; +import 'super_textfield_inspector.dart'; + +void main() { + group("SuperTextField on desktop", () { + group("overrides gestures", () { + testWidgetsOnDesktop("with widget properties", (tester) async { + int tapDownCount = 0; + + await _pumpScaffold( + tester, + child: SuperTextField( + gestureOverrideBuilder: (context, textLayoutResolver, [child]) { + return GestureDetector( + onTapDown: (details) { + tapDownCount += 1; + }, + behavior: HitTestBehavior.translucent, + child: Container( + decoration: BoxDecoration( + color: Colors.red.withOpacity(1.0), + ), + child: child, + ), + ); + }, + ), + ); + + // Attempt to place the caret with a tap. + await tester.tap(find.byType(SuperTextField)); + await tester.pump(kTapMinTime + const Duration(milliseconds: 1)); + + // Ensure that our override was called. + expect(tapDownCount, 1); + + // Ensure that our override prevented text field selection. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + }); + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + required Widget child, +}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: child, + ), + ), + ), + ), + ); +}