From 5e89455ff87f8ac07c409cee117bf9ff8a5c6c2d Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 18 Nov 2024 12:39:50 -0300 Subject: [PATCH] Allow multiple tap handlers --- .../document_gestures_mouse.dart | 129 ++++++++---- .../document_gestures_touch_android.dart | 87 ++++---- .../document_gestures_touch_ios.dart | 87 ++++---- .../lib/src/default_editor/super_editor.dart | 199 ++---------------- .../tap_handlers/tap_handlers.dart | 176 ++++++++++++++++ super_editor/lib/super_editor.dart | 2 + ...add_paragraph_at_end_tap_handler_test.dart | 5 +- .../super_editor/supereditor_test_tools.dart | 12 +- 8 files changed, 389 insertions(+), 308 deletions(-) create mode 100644 super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart 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 11a66c4b8..bdad63601 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; @@ -49,7 +50,7 @@ class DocumentMouseInteractor extends StatefulWidget { required this.getDocumentLayout, required this.selectionNotifier, required this.selectionChanges, - this.contentTapHandler, + this.contentTapHandlers, required this.autoScroller, required this.fillViewport, this.showDebugPaint = false, @@ -64,9 +65,12 @@ class DocumentMouseInteractor extends StatefulWidget { final Stream selectionChanges; final ValueListenable selectionNotifier; - /// Optional handler that responds to taps on content, e.g., opening + /// Optional list of handlers that respond to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. - final ContentTapDelegate? contentTapHandler; + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapHandlers; /// Auto-scrolling delegate. final AutoScrollController autoScroller; @@ -124,7 +128,12 @@ class _DocumentMouseInteractorState extends State with widget.autoScroller ..addListener(_updateDragSelection) ..addListener(_updateMouseCursorAtLatestOffset); - widget.contentTapHandler?.addListener(_updateMouseCursorAtLatestOffset); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } @override @@ -148,15 +157,28 @@ class _DocumentMouseInteractorState extends State with ..addListener(_updateDragSelection) ..addListener(_updateMouseCursorAtLatestOffset); } - if (widget.contentTapHandler != oldWidget.contentTapHandler) { - oldWidget.contentTapHandler?.removeListener(_updateMouseCursorAtLatestOffset); - widget.contentTapHandler?.addListener(_updateMouseCursorAtLatestOffset); + if (!const DeepCollectionEquality().equals(oldWidget.contentTapHandlers, widget.contentTapHandlers)) { + if (oldWidget.contentTapHandlers != null) { + for (final handler in oldWidget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } } @override void dispose() { - widget.contentTapHandler?.removeListener(_updateMouseCursorAtLatestOffset); + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } if (widget.focusNode == null) { _focusNode.dispose(); } @@ -253,18 +275,20 @@ class _DocumentMouseInteractorState extends State with _focusNode.requestFocus(); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -314,18 +338,20 @@ class _DocumentMouseInteractorState extends State with final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -421,18 +447,20 @@ class _DocumentMouseInteractorState extends State with final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -733,8 +761,17 @@ Updating drag selection: return; } - final cursorForContent = widget.contentTapHandler?.mouseCursorForContentHover(docPosition); - _mouseCursor.value = cursorForContent ?? SystemMouseCursors.text; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final cursorForContent = handler.mouseCursorForContentHover(docPosition); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + } + + _mouseCursor.value = SystemMouseCursors.text; } @override 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 4fb9d068b..85dec672f 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 @@ -408,7 +408,7 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { required this.openSoftwareKeyboard, required this.scrollController, required this.fillViewport, - this.contentTapHandler, + this.contentTapHandlers, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), required this.dragHandleAutoScroller, this.showDebugPaint = false, @@ -425,9 +425,12 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { /// A callback that should open the software keyboard when invoked. final VoidCallback openSoftwareKeyboard; - /// Optional handler that responds to taps on content, e.g., opening + /// Optional list of handlers that respond to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. - final ContentTapDelegate? contentTapHandler; + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapHandlers; final ScrollController scrollController; @@ -729,18 +732,20 @@ class _AndroidDocumentTouchInteractorState extends State? contentTapHandlers; final ScrollController scrollController; @@ -578,18 +581,20 @@ class _IosDocumentTouchInteractorState extends State final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -698,18 +703,20 @@ class _IosDocumentTouchInteractorState extends State final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } @@ -783,18 +790,20 @@ class _IosDocumentTouchInteractorState extends State final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap( - DocumentTapDetails( - documentLayout: _docLayout, - layoutOffset: docOffset, - globalOffset: details.globalPosition, - ), - ); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } } diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 2d9daec03..e30e6285c 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -20,15 +20,13 @@ import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart import 'package:super_editor/src/default_editor/document_scrollable.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_composing_region.dart'; import 'package:super_editor/src/default_editor/list_items.dart'; -import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/tap_handlers/tap_handlers.dart'; import 'package:super_editor/src/default_editor/tasks.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; -import 'package:super_editor/src/infrastructure/links.dart'; import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; @@ -128,7 +126,7 @@ class SuperEditor extends StatefulWidget { this.keyboardActions, this.selectorHandlers, this.gestureMode, - this.contentTapDelegateFactory = superEditorLaunchLinkTapHandlerFactory, + this.contentTapDelegateFactories = const [superEditorLaunchLinkTapHandlerFactory], this.selectionLayerLinks, this.documentUnderlayBuilders = const [], this.documentOverlayBuilders = defaultSuperEditorDocumentOverlayBuilders, @@ -272,12 +270,15 @@ class SuperEditor extends StatefulWidget { /// The `SuperEditor` gesture mode, e.g., mouse or touch. final DocumentGestureMode? gestureMode; - /// Factory that creates a [ContentTapDelegate], which is given an + /// List of factories that creates a [ContentTapDelegate], which is given an /// opportunity to respond to taps on content before the editor, itself. /// /// A [ContentTapDelegate] might be used, for example, to launch a URL /// when a user taps on a link. - final SuperEditorContentTapDelegateFactory? contentTapDelegateFactory; + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapDelegateFactories; /// Leader links that connect leader widgets near the user's selection /// to carets, handles, and other things that want to follow the selection. @@ -390,7 +391,7 @@ class SuperEditorState extends State { @visibleForTesting late SuperEditorContext editContext; - ContentTapDelegate? _contentTapDelegate; + List? _contentTapHandlers; final _dragHandleAutoScroller = ValueNotifier(null); @@ -510,7 +511,11 @@ class SuperEditorState extends State { @override void dispose() { - _contentTapDelegate?.dispose(); + if (_contentTapHandlers != null) { + for (final handler in _contentTapHandlers!) { + handler.dispose(); + } + } _iosControlsController.dispose(); _androidControlsController.dispose(); @@ -552,9 +557,13 @@ class SuperEditorState extends State { } // The ContentTapDelegate depends upon the EditContext. Recreate the - // delegate, now that we've created a new EditContext. - _contentTapDelegate?.dispose(); - _contentTapDelegate = widget.contentTapDelegateFactory?.call(editContext); + // handlers, now that we've created a new EditContext. + if (_contentTapHandlers != null) { + for (final handler in _contentTapHandlers!) { + handler.dispose(); + } + } + _contentTapHandlers = widget.contentTapDelegateFactories?.map((factory) => factory.call(editContext)).toList(); } void _createLayoutPresenter() { @@ -852,7 +861,7 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selectionChanges: editContext.composer.selectionChanges, selectionNotifier: editContext.composer.selectionNotifier, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: _contentTapHandlers, autoScroller: _autoScrollController, fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, @@ -866,7 +875,7 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, fillViewport: fillViewport, @@ -881,7 +890,7 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, fillViewport: fillViewport, @@ -1656,165 +1665,3 @@ TextStyle defaultStyleBuilder(Set attributions) { const defaultSelectionStyle = SelectionStyles( selectionColor: Color(0xFFACCEF7), ); - -typedef SuperEditorContentTapDelegateFactory = ContentTapDelegate Function(SuperEditorContext editContext); - -SuperEditorLaunchLinkTapHandler superEditorLaunchLinkTapHandlerFactory(SuperEditorContext editContext) => - SuperEditorLaunchLinkTapHandler(editContext.document, editContext.composer); - -/// A [ContentTapDelegate] that opens links when the user taps text with -/// a [LinkAttribution]. -/// -/// This delegate only opens links when [composer.isInInteractionMode] is -/// `true`. -class SuperEditorLaunchLinkTapHandler extends ContentTapDelegate { - SuperEditorLaunchLinkTapHandler(this.document, this.composer) { - composer.isInInteractionMode.addListener(notifyListeners); - } - - @override - void dispose() { - composer.isInInteractionMode.removeListener(notifyListeners); - super.dispose(); - } - - final Document document; - final DocumentComposer composer; - - @override - MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { - if (!composer.isInInteractionMode.value) { - // The editor isn't in "interaction mode". We don't want a special cursor - return null; - } - - final link = _getLinkAtPosition(hoverPosition); - return link != null ? SystemMouseCursors.click : null; - } - - @override - TapHandlingInstruction onTap(DocumentTapDetails details) { - final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); - if (tapPosition == null) { - return TapHandlingInstruction.continueHandling; - } - - if (!composer.isInInteractionMode.value) { - // The editor isn't in "interaction mode". We don't want to allow - // users to open links by tapping on them. - return TapHandlingInstruction.continueHandling; - } - - final link = _getLinkAtPosition(tapPosition); - if (link != null) { - // The user tapped on a link. Launch it. - UrlLauncher.instance.launchUrl(link); - return TapHandlingInstruction.halt; - } else { - // The user didn't tap on a link. - return TapHandlingInstruction.continueHandling; - } - } - - Uri? _getLinkAtPosition(DocumentPosition position) { - final nodePosition = position.nodePosition; - if (nodePosition is! TextNodePosition) { - return null; - } - - final textNode = document.getNodeById(position.nodeId); - if (textNode is! TextNode) { - editorGesturesLog - .shout("Received a report of a tap on a TextNodePosition, but the node with that ID is a: $textNode"); - return null; - } - - final tappedAttributions = textNode.text.getAllAttributionsAt(nodePosition.offset); - for (final tappedAttribution in tappedAttributions) { - if (tappedAttribution is LinkAttribution) { - return tappedAttribution.uri; - } - } - - return null; - } -} - -SuperEditorAddEmptyParagraphTapHandler superEditorAddEmptyParagraphTapHandlerFactory(SuperEditorContext editContext) => - SuperEditorAddEmptyParagraphTapHandler(editContext: editContext); - -/// A [ContentTapDelegate] that adds an empty paragraph at the end of the document -/// when the user taps below the last node in the document. -/// -/// Does nothing if the last node is a [TextNode]. -class SuperEditorAddEmptyParagraphTapHandler extends ContentTapDelegate { - SuperEditorAddEmptyParagraphTapHandler({ - required this.editContext, - }); - - final SuperEditorContext editContext; - - @override - TapHandlingInstruction onTap(DocumentTapDetails details) { - final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); - if (tapPosition == null) { - return TapHandlingInstruction.continueHandling; - } - - final editor = editContext.editor; - final document = editContext.document; - - final node = document.getNodeById(tapPosition.nodeId)!; - if (node is TextNode) { - return TapHandlingInstruction.continueHandling; - } - - if (!_isTapBelowLastNode( - nodeId: tapPosition.nodeId, - globalOffset: details.globalOffset, - )) { - return TapHandlingInstruction.continueHandling; - } - - // The user tapped below a non-text node. Add a new paragraph - // to the end of the document and place the caret there. - final newNodeId = Editor.createNodeId(); - editor.execute([ - InsertNodeAfterNodeRequest( - existingNodeId: node.id, - newNode: ParagraphNode( - id: newNodeId, - text: AttributedText(), - ), - ), - ChangeSelectionRequest( - DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: newNodeId, - nodePosition: const TextNodePosition(offset: 0), - ), - ), - SelectionChangeType.insertContent, - SelectionReason.userInteraction, - ), - const ClearComposingRegionRequest(), - ]); - - return TapHandlingInstruction.halt; - } - - bool _isTapBelowLastNode({ - required String nodeId, - required Offset globalOffset, - }) { - final documentLayout = editContext.documentLayout; - final document = editContext.document; - - final tappedComponent = documentLayout.getComponentByNodeId(nodeId)!; - final componentBox = tappedComponent.context.findRenderObject() as RenderBox; - final localPosition = componentBox.globalToLocal(globalOffset); - final nodeIndex = document.getNodeIndexById(nodeId); - - return (nodeIndex == document.nodeCount - 1) && (localPosition.dy > componentBox.size.height); - } -} diff --git a/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart new file mode 100644 index 000000000..705497bbd --- /dev/null +++ b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart @@ -0,0 +1,176 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/rendering.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.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/links.dart'; + +typedef SuperEditorContentTapDelegateFactory = ContentTapDelegate Function(SuperEditorContext editContext); + +SuperEditorLaunchLinkTapHandler superEditorLaunchLinkTapHandlerFactory(SuperEditorContext editContext) => + SuperEditorLaunchLinkTapHandler(editContext.document, editContext.composer); + +/// A [ContentTapDelegate] that opens links when the user taps text with +/// a [LinkAttribution]. +/// +/// This delegate only opens links when [composer.isInInteractionMode] is +/// `true`. +class SuperEditorLaunchLinkTapHandler extends ContentTapDelegate { + SuperEditorLaunchLinkTapHandler(this.document, this.composer) { + composer.isInInteractionMode.addListener(notifyListeners); + } + + @override + void dispose() { + composer.isInInteractionMode.removeListener(notifyListeners); + super.dispose(); + } + + final Document document; + final DocumentComposer composer; + + @override + MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { + if (!composer.isInInteractionMode.value) { + // The editor isn't in "interaction mode". We don't want a special cursor + return null; + } + + final link = _getLinkAtPosition(hoverPosition); + return link != null ? SystemMouseCursors.click : null; + } + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + if (!composer.isInInteractionMode.value) { + // The editor isn't in "interaction mode". We don't want to allow + // users to open links by tapping on them. + return TapHandlingInstruction.continueHandling; + } + + final link = _getLinkAtPosition(tapPosition); + if (link != null) { + // The user tapped on a link. Launch it. + UrlLauncher.instance.launchUrl(link); + return TapHandlingInstruction.halt; + } else { + // The user didn't tap on a link. + return TapHandlingInstruction.continueHandling; + } + } + + Uri? _getLinkAtPosition(DocumentPosition position) { + final nodePosition = position.nodePosition; + if (nodePosition is! TextNodePosition) { + return null; + } + + final textNode = document.getNodeById(position.nodeId); + if (textNode is! TextNode) { + editorGesturesLog + .shout("Received a report of a tap on a TextNodePosition, but the node with that ID is a: $textNode"); + return null; + } + + final tappedAttributions = textNode.text.getAllAttributionsAt(nodePosition.offset); + for (final tappedAttribution in tappedAttributions) { + if (tappedAttribution is LinkAttribution) { + return tappedAttribution.uri; + } + } + + return null; + } +} + +SuperEditorAddEmptyParagraphTapHandler superEditorAddEmptyParagraphTapHandlerFactory(SuperEditorContext editContext) => + SuperEditorAddEmptyParagraphTapHandler(editContext: editContext); + +/// A [ContentTapDelegate] that adds an empty paragraph at the end of the document +/// when the user taps below the last node in the document. +/// +/// Does nothing if the last node is a [TextNode]. +class SuperEditorAddEmptyParagraphTapHandler extends ContentTapDelegate { + SuperEditorAddEmptyParagraphTapHandler({ + required this.editContext, + }); + + final SuperEditorContext editContext; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final editor = editContext.editor; + final document = editContext.document; + + final node = document.getNodeById(tapPosition.nodeId)!; + if (node is TextNode) { + return TapHandlingInstruction.continueHandling; + } + + if (!_isTapBelowLastNode( + nodeId: tapPosition.nodeId, + globalOffset: details.globalOffset, + )) { + return TapHandlingInstruction.continueHandling; + } + + // The user tapped below a non-text node. Add a new paragraph + // to the end of the document and place the caret there. + final newNodeId = Editor.createNodeId(); + editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: node.id, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + + return TapHandlingInstruction.halt; + } + + bool _isTapBelowLastNode({ + required String nodeId, + required Offset globalOffset, + }) { + final documentLayout = editContext.documentLayout; + final document = editContext.document; + + final tappedComponent = documentLayout.getComponentByNodeId(nodeId)!; + final componentBox = tappedComponent.context.findRenderObject() as RenderBox; + final localPosition = componentBox.globalToLocal(globalOffset); + final nodeIndex = document.getNodeIndexById(nodeId); + + return (nodeIndex == document.nodeCount - 1) && (localPosition.dy > componentBox.size.height); + } +} diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 39059cec3..f9242fa77 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -93,6 +93,8 @@ export 'src/infrastructure/popovers.dart'; export 'src/infrastructure/selectable_list.dart'; export 'src/infrastructure/actions.dart'; +export 'src/default_editor/tap_handlers/tap_handlers.dart'; + // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; export 'src/super_reader/read_only_document_ios_touch_interactor.dart'; diff --git a/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart b/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart index 9c3b5e0ac..35c62c974 100644 --- a/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart +++ b/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart @@ -27,8 +27,7 @@ void main() { ], )) .withEditorSize(const Size(500, 1000)) - .withTapDelegateFactory(superEditorAddEmptyParagraphTapHandlerFactory) - .pump(); + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]).pump(); // Tap below the end of the document and wait for the double tap // timeout to expire. @@ -71,7 +70,7 @@ void main() { ], )) .withEditorSize(const Size(500, 1000)) - .withTapDelegateFactory(superEditorAddEmptyParagraphTapHandlerFactory) + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]) // .pump(); // Tap below the end of the document and wait for the double tap diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index b6186e9d6..852b79cd8 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -353,9 +353,10 @@ class TestSuperEditorConfigurator { return this; } - /// Configures the [SuperEditor] to use the given [tapDelegateFactory]. - TestSuperEditorConfigurator withTapDelegateFactory(SuperEditorContentTapDelegateFactory? tapDelegateFactory) { - _config.tapDelegateFactory = tapDelegateFactory; + /// Configures the [SuperEditor] to use only the given [tapDelegateFactories]. + TestSuperEditorConfigurator withTapDelegateFactories( + List? tapDelegateFactories) { + _config.tapDelegateFactories = tapDelegateFactories; return this; } @@ -594,7 +595,8 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { focusNode: widget.testDocumentContext.focusNode, autofocus: widget.testConfiguration.autoFocus, tapRegionGroupId: widget.testConfiguration.tapRegionGroupId, - contentTapDelegateFactory: widget.testConfiguration.tapDelegateFactory ?? superEditorLaunchLinkTapHandlerFactory, + contentTapDelegateFactories: + widget.testConfiguration.tapDelegateFactories ?? [superEditorLaunchLinkTapHandlerFactory], editor: widget.testDocumentContext.editor, documentLayoutKey: widget.testDocumentContext.layoutKey, inputSource: widget.testConfiguration.inputSource, @@ -720,7 +722,7 @@ class SuperEditorTestConfiguration { DocumentSelection? selection; - SuperEditorContentTapDelegateFactory? tapDelegateFactory; + List? tapDelegateFactories; final plugins = {};