diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index e8aa060ab..373090365 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -2,8 +2,8 @@ import 'dart:math'; import 'package:example/logging.dart'; import 'package:flutter/material.dart'; -import 'package:super_editor/super_editor.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:super_editor/super_editor.dart'; /// Small toolbar that is intended to display near some selected /// text and offer a few text formatting controls. diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 62414831e..41c69981c 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -1,6 +1,6 @@ import 'package:example/demos/example_editor/_task.dart'; -import 'package:flutter/foundation.dart'; import 'package:example/logging.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 052f6cecf..3eaec63da 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -3,22 +3,21 @@ import 'package:example/demos/components/demo_unselectable_hr.dart'; import 'package:example/demos/debugging/simple_deltas_input.dart'; import 'package:example/demos/demo_app_shortcuts.dart'; import 'package:example/demos/demo_empty_document.dart'; -import 'package:example/demos/demo_rtl.dart'; import 'package:example/demos/demo_markdown_serialization.dart'; import 'package:example/demos/demo_paragraphs.dart'; +import 'package:example/demos/demo_rtl.dart'; import 'package:example/demos/demo_selectable_text.dart'; import 'package:example/demos/editor_configs/demo_mobile_editing_android.dart'; import 'package:example/demos/editor_configs/demo_mobile_editing_ios.dart'; import 'package:example/demos/example_editor/example_editor.dart'; import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; -import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart'; -import 'package:example/demos/styles/demo_doc_styles.dart'; -import 'package:example/demos/supertextfield/ios/demo_superiostextfield.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; +import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart'; import 'package:example/demos/sliver_example_editor.dart'; +import 'package:example/demos/styles/demo_doc_styles.dart'; import 'package:example/demos/supertextfield/demo_textfield.dart'; -import 'package:example/logging.dart'; +import 'package:example/demos/supertextfield/ios/demo_superiostextfield.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -117,25 +116,29 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { - // We need our own [Overlay] instead of the one created by the navigator - // because overlay entries added to navigator's [Overlay] are always - // displayed above all routes. - // - // We display the editor's toolbar in an [OverlayEntry], so inserting it - // at the navigator's [Overlay] causes widgets that are displayed in routes, - // e.g. [DropdownButton] items, to be displayed beneath the toolbar. - return Overlay( - initialEntries: [ - OverlayEntry(builder: (context) { - return Scaffold( - key: _scaffoldKey, - appBar: _buildAppBar(context), - extendBodyBehindAppBar: true, - body: _selectedMenuItem!.pageBuilder(context), - drawer: _buildDrawer(), - ); - }) - ], + // We need a FocusScope above the Overlay so that focus can be shared between + // SuperEditor in one OverlayEntry, and the popover toolbar in another OverlayEntry. + return FocusScope( + // We need our own [Overlay] instead of the one created by the navigator + // because overlay entries added to navigator's [Overlay] are always + // displayed above all routes. + // + // We display the editor's toolbar in an [OverlayEntry], so inserting it + // at the navigator's [Overlay] causes widgets that are displayed in routes, + // e.g. [DropdownButton] items, to be displayed beneath the toolbar. + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) { + return Scaffold( + key: _scaffoldKey, + appBar: _buildAppBar(context), + extendBodyBehindAppBar: true, + body: _selectedMenuItem!.pageBuilder(context), + drawer: _buildDrawer(), + ); + }) + ], + ), ); } diff --git a/super_editor/lib/src/core/document_layout.dart b/super_editor/lib/src/core/document_layout.dart index 6103364ba..c7c858b2b 100644 --- a/super_editor/lib/src/core/document_layout.dart +++ b/super_editor/lib/src/core/document_layout.dart @@ -269,76 +269,78 @@ mixin DocumentComponent on State { /// to provide is [childDocumentComponentKey], which is a `GlobalKey` that provides /// access to the child [DocumentComponent]. mixin ProxyDocumentComponent implements DocumentComponent { - DocumentComponent get childDocumentComponentKey; + GlobalKey get childDocumentComponentKey; + + DocumentComponent get childDocumentComponent => childDocumentComponentKey.currentState as DocumentComponent; @override NodePosition? getPositionAtOffset(Offset localOffset) { - return childDocumentComponentKey.getPositionAtOffset(localOffset); + return childDocumentComponent.getPositionAtOffset(localOffset); } @override Offset getOffsetForPosition(NodePosition nodePosition) { - return childDocumentComponentKey.getOffsetForPosition(nodePosition); + return childDocumentComponent.getOffsetForPosition(nodePosition); } @override Rect getRectForPosition(NodePosition nodePosition) { - return childDocumentComponentKey.getRectForPosition(nodePosition); + return childDocumentComponent.getRectForPosition(nodePosition); } @override Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { - return childDocumentComponentKey.getRectForSelection(baseNodePosition, extentNodePosition); + return childDocumentComponent.getRectForSelection(baseNodePosition, extentNodePosition); } @override NodePosition getBeginningPosition() { - return childDocumentComponentKey.getBeginningPosition(); + return childDocumentComponent.getBeginningPosition(); } @override NodePosition getBeginningPositionNearX(double x) { - return childDocumentComponentKey.getBeginningPositionNearX(x); + return childDocumentComponent.getBeginningPositionNearX(x); } @override NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { - return childDocumentComponentKey.movePositionLeft(currentPosition, movementModifier); + return childDocumentComponent.movePositionLeft(currentPosition, movementModifier); } @override NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { - return childDocumentComponentKey.movePositionRight(currentPosition, movementModifier); + return childDocumentComponent.movePositionRight(currentPosition, movementModifier); } @override NodePosition? movePositionUp(NodePosition currentPosition) { - return childDocumentComponentKey.movePositionUp(currentPosition); + return childDocumentComponent.movePositionUp(currentPosition); } @override NodePosition? movePositionDown(NodePosition currentPosition) { - return childDocumentComponentKey.movePositionDown(currentPosition); + return childDocumentComponent.movePositionDown(currentPosition); } @override NodePosition getEndPosition() { - return childDocumentComponentKey.getEndPosition(); + return childDocumentComponent.getEndPosition(); } @override NodePosition getEndPositionNearX(double x) { - return childDocumentComponentKey.getEndPositionNearX(x); + return childDocumentComponent.getEndPositionNearX(x); } @override NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { - return childDocumentComponentKey.getSelectionInRange(localBaseOffset, localExtentOffset); + return childDocumentComponent.getSelectionInRange(localBaseOffset, localExtentOffset); } @override NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { - return childDocumentComponentKey.getCollapsedSelectionAt(nodePosition); + return childDocumentComponent.getCollapsedSelectionAt(nodePosition); } @override @@ -346,20 +348,20 @@ mixin ProxyDocumentComponent implements DocumentCompon required NodePosition basePosition, required NodePosition extentPosition, }) { - return childDocumentComponentKey.getSelectionBetween(basePosition: basePosition, extentPosition: extentPosition); + return childDocumentComponent.getSelectionBetween(basePosition: basePosition, extentPosition: extentPosition); } @override NodeSelection getSelectionOfEverything() { - return childDocumentComponentKey.getSelectionOfEverything(); + return childDocumentComponent.getSelectionOfEverything(); } @override - bool isVisualSelectionSupported() => childDocumentComponentKey.isVisualSelectionSupported(); + bool isVisualSelectionSupported() => childDocumentComponent.isVisualSelectionSupported(); @override MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { - return childDocumentComponentKey.getDesiredCursorAtOffset(localOffset); + return childDocumentComponent.getDesiredCursorAtOffset(localOffset); } } diff --git a/super_editor/lib/src/default_editor/box_component.dart b/super_editor/lib/src/default_editor/box_component.dart index 7beb247e7..8ffe1016b 100644 --- a/super_editor/lib/src/default_editor/box_component.dart +++ b/super_editor/lib/src/default_editor/box_component.dart @@ -278,13 +278,16 @@ class SelectableBox extends StatelessWidget { Widget build(BuildContext context) { final isSelected = selection != null && !selection!.isCollapsed; - return IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - color: isSelected ? selectionColor.withOpacity(0.5) : Colors.transparent, + return MouseRegion( + cursor: SystemMouseCursors.basic, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected ? selectionColor.withOpacity(0.5) : Colors.transparent, + ), + position: DecorationPosition.foreground, + child: child, ), - position: DecorationPosition.foreground, - child: child, ), ); } diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index 304160b29..249202b12 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -69,10 +69,6 @@ class _DocumentMouseInteractorState extends State with Offset? _dragEndGlobal; bool _expandSelectionDuringDrag = false; - // Current mouse cursor style displayed on screen. - Offset? _cursorGlobalOffset; - final _cursorStyle = ValueNotifier(SystemMouseCursors.basic); - @override void initState() { super.initState(); @@ -329,7 +325,6 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog.info("Pan start on document, global offset: ${details.globalPosition}"); _dragStartGlobal = details.globalPosition; - _cursorGlobalOffset = details.globalPosition; widget.autoScroller.enableAutoScrolling(); @@ -352,9 +347,7 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog.info("Pan update on document, global offset: ${details.globalPosition}"); _dragEndGlobal = details.globalPosition; - _cursorGlobalOffset = details.globalPosition; - _updateCursorStyle(); _updateDragSelection(); widget.autoScroller.setGlobalAutoScrollRegion( @@ -394,22 +387,6 @@ class _DocumentMouseInteractorState extends State with } } - void _onMouseMove(PointerEvent pointerEvent) { - _cursorGlobalOffset = pointerEvent.position; - _updateCursorStyle(); - } - - void _updateCursorStyle() { - final cursorOffsetInDocument = _getDocOffsetFromGlobalOffset(_cursorGlobalOffset!); - final desiredCursor = _docLayout.getDesiredCursorAtOffset(cursorOffsetInDocument); - - if (desiredCursor != null && desiredCursor != _cursorStyle.value) { - _cursorStyle.value = desiredCursor; - } else if (desiredCursor == null && _cursorStyle.value != SystemMouseCursors.basic) { - _cursorStyle.value = SystemMouseCursors.basic; - } - } - void _updateDragSelection() { if (_dragEndGlobal == null) { // User isn't dragging. No need to update drag selection. @@ -538,17 +515,8 @@ Updating drag selection: Widget _buildCursorStyle({ required Widget child, }) { - return AnimatedBuilder( - animation: _cursorStyle, - builder: (context, child) { - return Listener( - onPointerHover: _onMouseMove, - child: MouseRegion( - cursor: _cursorStyle.value, - child: child, - ), - ); - }, + return MouseRegion( + cursor: SystemMouseCursors.text, child: child, ); } diff --git a/super_editor/lib/src/default_editor/document_input_ime.dart b/super_editor/lib/src/default_editor/document_input_ime.dart index dbaf6fdea..1f8a8e2eb 100644 --- a/super_editor/lib/src/default_editor/document_input_ime.dart +++ b/super_editor/lib/src/default_editor/document_input_ime.dart @@ -67,7 +67,7 @@ class DocumentImeInteractor extends StatefulWidget { final Widget child; @override - _DocumentImeInteractorState createState() => _DocumentImeInteractorState(); + State createState() => _DocumentImeInteractorState(); } class _DocumentImeInteractorState extends State implements DeltaTextInputClient { @@ -862,7 +862,7 @@ class SoftwareKeyboardHandler { // On iOS, newlines are reported here and also to performAction(). // On Android and web, newlines are only reported here. So, on Android and web, // we forward the newline action to performAction. - if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { + if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { editorImeLog.fine("Received a newline insertion on Android. Forwarding to newline input action."); performAction(TextInputAction.newline); } else { diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 949f3f4ae..fa89421ce 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'dart:math'; import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; @@ -329,7 +328,7 @@ class _TextWithHintComponentState extends State final _childTextComponentKey = GlobalKey(); @override - DocumentComponent get childDocumentComponentKey => _childTextComponentKey.currentState!; + GlobalKey get childDocumentComponentKey => _childTextComponentKey; @override TextComposable get childTextComposable => _childTextComponentKey.currentState!; @@ -351,8 +350,10 @@ class _TextWithHintComponentState extends State return Stack( children: [ if (widget.text.text.isEmpty) - Text.rich( - widget.hintText?.computeTextSpan(_styleBuilder) ?? const TextSpan(text: ''), + IgnorePointer( + child: Text.rich( + widget.hintText?.computeTextSpan(_styleBuilder) ?? const TextSpan(text: ''), + ), ), TextComponent( key: _childTextComponentKey, diff --git a/super_editor/test/super_editor/supereditor_components_test.dart b/super_editor/test/super_editor/supereditor_components_test.dart new file mode 100644 index 000000000..92f734572 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_components_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../test_tools.dart'; +import 'document_test_tools.dart'; +import 'supereditor_inspector.dart'; +import 'supereditor_robot.dart'; + +void main() { + group("SuperEditor component", () { + testWidgetsOnMac("HintTextComponent places caret on tap", (tester) async { + // Based on bug #726 + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withAddedComponents([const HintTextComponentBuilder()]) + .autoFocus(false) + .pump(); + + // Tap on the hint text component to place the caret. + await tester.placeCaretInParagraph("1", 0); + + // Ensure that the document now shows the caret within the hint text component. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + }); +} + +class HintTextComponentBuilder implements ComponentBuilder { + const HintTextComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + // This component builder can work with the standard paragraph view model. + // We'll defer to the standard paragraph component builder to create it. + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! ParagraphComponentViewModel) { + return null; + } + + final textSelection = componentViewModel.selection; + + return TextWithHintComponent( + key: componentContext.componentKey, + text: componentViewModel.text, + textStyleBuilder: defaultStyleBuilder, + metadata: componentViewModel.blockType != null + ? { + 'blockType': componentViewModel.blockType, + } + : {}, + // This is the text displayed as a hint. + hintText: AttributedText( + text: 'this is hint text...', + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 15, markerType: SpanMarkerType.end), + ], + ), + ), + // This is the function that selects styles for the hint text. + hintStyleBuilder: (Set attributions) => defaultStyleBuilder(attributions).copyWith( + color: const Color(0xFFDDDDDD), + ), + textSelection: textSelection, + selectionColor: componentViewModel.selectionColor, + ); + } +} diff --git a/super_editor/test/super_editor/supereditor_robot.dart b/super_editor/test/super_editor/supereditor_robot.dart index f302cc82c..3057fb580 100644 --- a/super_editor/test/super_editor/supereditor_robot.dart +++ b/super_editor/test/super_editor/supereditor_robot.dart @@ -39,8 +39,14 @@ extension SuperEditorRobot on WidgetTester { // Collect the various text UI artifacts needed to find the // desired caret offset. - final textComponentState = documentLayout.getComponentByNodeId(nodeId) as State; - final textComponentKey = textComponentState.widget.key as GlobalKey; + final componentState = documentLayout.getComponentByNodeId(nodeId) as State; + late final GlobalKey textComponentKey; + if (componentState is ProxyDocumentComponent) { + textComponentKey = componentState.childDocumentComponentKey; + } else { + textComponentKey = componentState.widget.key as GlobalKey; + } + final textLayout = (textComponentKey.currentState as TextComponentState).textLayout; final textRenderBox = textComponentKey.currentContext!.findRenderObject() as RenderBox;