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 ca0caea8b4..bdad636014 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(); } @@ -250,26 +272,34 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog.info("Tap up on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); _focusNode.requestFocus(); + 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; + } + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition == null) { editorGesturesLog.fine("No document content at ${details.globalPosition}."); _clearSelection(); return; } - if (widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTap(docPosition); - if (result == TapHandlingInstruction.halt) { - // The custom tap handler doesn't want us to react at all - // to the tap. - return; - } - } - final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; final expandSelection = _isShiftPressed && _currentSelection != null; @@ -307,18 +337,26 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog.info("Double tap down on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap(docPosition); - 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; + } } } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -408,18 +446,26 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog.info("Triple down down on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap(docPosition); - 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; + } } } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -715,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 253f2ed385..fd4ecc7576 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 @@ -405,7 +405,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, @@ -422,9 +422,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; @@ -725,18 +728,27 @@ class _AndroidDocumentTouchInteractorState extends State? contentTapHandlers; final ScrollController scrollController; @@ -577,18 +580,26 @@ class _IosDocumentTouchInteractorState extends State editorGesturesLog.info("Tap down on document"); final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (widget.contentTapHandler != null && docPosition != null) { - final result = widget.contentTapHandler!.onTap(docPosition); - 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; + } } } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition != null && selection != null && !selection.isCollapsed && @@ -705,18 +716,26 @@ class _IosDocumentTouchInteractorState extends State editorGesturesLog.info("Double tap down on document"); final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onDoubleTap(docPosition); - 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; + } } } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -784,18 +803,26 @@ class _IosDocumentTouchInteractorState extends State final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && widget.contentTapHandler != null) { - final result = widget.contentTapHandler!.onTripleTap(docPosition); - 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; + } } } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index aa3506c6e4..9427f38898 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -20,14 +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/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 +127,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, @@ -278,12 +277,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. @@ -396,7 +398,7 @@ class SuperEditorState extends State { @visibleForTesting late SuperEditorContext editContext; - ContentTapDelegate? _contentTapDelegate; + List? _contentTapHandlers; final _dragHandleAutoScroller = ValueNotifier(null); @@ -516,7 +518,11 @@ class SuperEditorState extends State { @override void dispose() { - _contentTapDelegate?.dispose(); + if (_contentTapHandlers != null) { + for (final handler in _contentTapHandlers!) { + handler.dispose(); + } + } _iosControlsController.dispose(); _androidControlsController.dispose(); @@ -558,9 +564,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() { @@ -859,7 +869,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, @@ -873,7 +883,7 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, fillViewport: fillViewport, @@ -888,7 +898,7 @@ class SuperEditorState extends State { getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, openSoftwareKeyboard: _openSoftareKeyboard, - contentTapHandler: _contentTapDelegate, + contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, fillViewport: fillViewport, @@ -1660,81 +1670,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(DocumentPosition tapPosition) { - 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; - } -} 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 0000000000..705497bbd0 --- /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/src/infrastructure/document_gestures_interaction_overrides.dart b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart index 27be43c7a1..1b06841a0c 100644 --- a/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart +++ b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; /// Delegate for mouse status and clicking on special types of content, /// e.g., tapping on a link open the URL. @@ -12,19 +13,42 @@ abstract class ContentTapDelegate with ChangeNotifier { return null; } - TapHandlingInstruction onTap(DocumentPosition tapPosition) { + TapHandlingInstruction onTap(DocumentTapDetails details) { return TapHandlingInstruction.continueHandling; } - TapHandlingInstruction onDoubleTap(DocumentPosition tapPosition) { + TapHandlingInstruction onDoubleTap(DocumentTapDetails details) { return TapHandlingInstruction.continueHandling; } - TapHandlingInstruction onTripleTap(DocumentPosition tapPosition) { + TapHandlingInstruction onTripleTap(DocumentTapDetails details) { return TapHandlingInstruction.continueHandling; } } +/// Information about a gesture that occured within a [DocumentLayout]. +class DocumentTapDetails { + DocumentTapDetails({ + required this.documentLayout, + required this.layoutOffset, + required this.globalOffset, + }); + + /// The document layout. + /// + /// It can be used to pull information about the logical position + /// where the tap occurred. For example, to find the [DocumentPosition] + /// that is nearest to the tap, to find if the tap ocurred above + /// the first node or below the last node, etc. + final DocumentLayout documentLayout; + + /// The position of the gesture in [DocumentLayout]'s coordinate space. + final Offset layoutOffset; + + /// The position of the gesture in global coordinates. + final Offset globalOffset; +} + enum TapHandlingInstruction { halt, continueHandling, 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 2d8f56ae58..2482ee4087 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 @@ -527,11 +527,15 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State SuperEditorAddEmptyParagraphTapHandler > ', () { + group('when tapping below the end of the document', () { + testWidgetsOnAllPlatforms('adds a new empty paragraph when the last node is a non-text node', (tester) async { + // Pump an editor with a height big enough so we know we can tap + // at a space after the document ends. + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('First paragraph'), + ), + HorizontalRuleNode(id: 'hr') + ], + )) + .withEditorSize(const Size(500, 1000)) + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]).pump(); + + // Tap below the end of the document and wait for the double tap + // timeout to expire. + await tester.tapAt(const Offset(490, 990)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure a new empty paragraph was added. + expect(document.nodeCount, equals(3)); + expect(document.last, isA()); + expect((document.last as ParagraphNode).text.text, isEmpty); + + // Ensure the selection was placed in the newly added paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('does nothing when the last node is a text node', (tester) async { + // Pump an editor with a height big enough so we know we can tap + // at a space after the document ends. + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + HorizontalRuleNode(id: 'hr'), + ParagraphNode( + id: '1', + text: AttributedText('First paragraph'), + ), + ], + )) + .withEditorSize(const Size(500, 1000)) + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]) // + .pump(); + + // Tap below the end of the document and wait for the double tap + // timeout to expire. + await tester.tapAt(const Offset(490, 990)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the existing paragraph was kept. + expect(document.nodeCount, equals(2)); + expect(document.last, isA()); + expect((document.last as ParagraphNode).text.text, 'First paragraph'); + + // Ensure the selection was placed at the end of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 15), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index a1d2a32e0a..e8e512c55f 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -386,6 +386,13 @@ class TestSuperEditorConfigurator { return this; } + /// Configures the [SuperEditor] to use only the given [tapDelegateFactories]. + TestSuperEditorConfigurator withTapDelegateFactories( + List? tapDelegateFactories) { + _config.tapDelegateFactories = tapDelegateFactories; + return this; + } + /// Applies the given [plugin] to the pumped [SuperEditor]. TestSuperEditorConfigurator withPlugin(SuperEditorPlugin plugin) { _config.plugins.add(plugin); @@ -643,6 +650,8 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { focusNode: widget.testDocumentContext.focusNode, autofocus: widget.testConfiguration.autoFocus, tapRegionGroupId: widget.testConfiguration.tapRegionGroupId, + contentTapDelegateFactories: + widget.testConfiguration.tapDelegateFactories ?? [superEditorLaunchLinkTapHandlerFactory], editor: widget.testDocumentContext.editor, documentLayoutKey: widget.testDocumentContext.layoutKey, inputSource: widget.testConfiguration.inputSource, @@ -779,6 +788,8 @@ class SuperEditorTestConfiguration { DocumentSelection? selection; + List? tapDelegateFactories; + final plugins = {}; WidgetTreeBuilder? widgetTreeBuilder;