diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart index ef0f214eb..8a61e55ae 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart @@ -157,6 +157,11 @@ class SuperEditorIosControlsController { /// Controls the iOS floating cursor. late final FloatingCursorController floatingCursorController; + /// Reports the offset where the caret is being dragged, in content space. + /// + /// If the caret is not being dragged, this value is `null`. + final ValueNotifier caretDragOffset = ValueNotifier(null); + /// Whether the iOS magnifier should be displayed right now. ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; final _shouldShowMagnifier = ValueNotifier(false); @@ -1053,6 +1058,8 @@ class _IosDocumentTouchInteractorState extends State } if (_dragHandleType == HandleType.collapsed) { + _controlsController!.caretDragOffset.value = + _interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalDragOffset!)); widget.editor.execute([ ChangeSelectionRequest( DocumentSelection.collapsed( @@ -1136,6 +1143,8 @@ class _IosDocumentTouchInteractorState extends State _onHandleDragEnd(); } + _controlsController!.caretDragOffset.value = null; + widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); scrollPosition.removeListener(_onAutoScrollChange); } @@ -1327,18 +1336,26 @@ class _IosDocumentTouchInteractorState extends State // A drag isn't happening. Magnify the position that the user tapped. docPositionToMagnify = _docLayout.getDocumentPositionNearestToOffset(_globalTapDownOffset! + Offset(0, scrollPosition.pixels)); + final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( + _docLayout.getRectForPosition(docPositionToMagnify!)!.center, + ); + + _magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset; } else { final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; docPositionToMagnify = _docLayout .getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta - Offset(0, dragScrollDelta)); + final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( + _docLayout.getRectForPosition(docPositionToMagnify!)!.center, + ); + _magnifierFocalPointInDocumentSpace.value = Offset( + _interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalDragOffset!)).dx, + // The user can move the caret freely in the x-axis, but the caret is snaped to the selection + // at the y-axis. + centerOfContentAtOffset.dy, + ); } - - final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( - _docLayout.getRectForPosition(docPositionToMagnify!)!.center, - ); - - _magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset; } @override @@ -1939,6 +1956,8 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild return const ContentLayerProxyWidget(child: SizedBox()); } + final controlsScope = SuperEditorIosControlsScope.rootOf(context); + return IosHandlesDocumentLayer( document: editContext.document, documentLayout: editContext.documentLayout, @@ -1949,13 +1968,12 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild const ClearComposingRegionRequest(), ]); }, - handleColor: handleColor ?? - SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ?? - Theme.of(context).primaryColor, + handleColor: handleColor ?? controlsScope.handleColor ?? Theme.of(context).primaryColor, caretWidth: caretWidth ?? 2, handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter, - shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink, - floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController, + shouldCaretBlink: controlsScope.shouldCaretBlink, + floatingCursorController: controlsScope.floatingCursorController, + caretDragOffset: controlsScope.caretDragOffset, ); } } diff --git a/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart index 93fff3ba9..10707c3b1 100644 --- a/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart +++ b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart @@ -194,12 +194,14 @@ class _SelectionLeadersDocumentLayerState class DocumentSelectionLayout { DocumentSelectionLayout({ this.caret, + this.ghostCaret, this.upstream, this.downstream, this.expandedSelectionBounds, }); final Rect? caret; + final Rect? ghostCaret; final Rect? upstream; final Rect? downstream; final Rect? expandedSelectionBounds; diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart index 8ed051754..1311b43f8 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart @@ -14,6 +14,7 @@ import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/floating_cursor.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; @@ -501,6 +502,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { this.handleBallDiameter = defaultIosHandleBallDiameter, required this.shouldCaretBlink, this.floatingCursorController, + this.caretDragOffset, this.showDebugPaint = false, }); @@ -529,6 +531,11 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { /// caret when the floating cursor is far away from its nearest text. final FloatingCursorController? floatingCursorController; + /// The offset where the caret is being dragged, in content space. + /// + /// If the caret is not being dragged, this value must be `null`. + final ValueListenable? caretDragOffset; + final bool showDebugPaint; @override @@ -538,7 +545,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { @visibleForTesting class IosControlsDocumentLayerState extends DocumentLayoutLayerState - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { // These global keys are assigned to each draggable handle to // prevent a strange dragging issue. // @@ -562,8 +569,15 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState layoutData?.downstream != null; + /// The most recent caret position while the user is dragging it, in content space. + /// + /// Used to detect when the user releases the caret. + Offset? _latestCaretDragOffset; + + /// Controls the animation of the caret moving from the position where + /// the user released the finger to the actual selected position. + /// + /// For example, the user can release the finger between characters or + /// far from the text. The caret then animates to the closest legal position. + late AnimationController _caretReleaseAnimationController; + void _onSelectionChange() { _updateCaretFlash(); setState(() { @@ -628,6 +661,22 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState= contentBox.size.width) { + if (contentBox != null && contentBox.hasSize && snapedCaretRect.left + caretWidth >= contentBox.size.width) { // Ajust the caret position to make it entirely visible because it's currently placed // partially or entirely outside of the layers' bounds. This can happen for downstream selections // of block components that take all the available width. - caretRect = Rect.fromLTWH( + snapedCaretRect = Rect.fromLTWH( contentBox.size.width - caretWidth, - caretRect.top, - caretRect.width, - caretRect.height, + snapedCaretRect.top, + snapedCaretRect.width, + snapedCaretRect.height, ); } + Rect caretRect = snapedCaretRect; + Rect? ghostCaretRect; + + final caretDragOffset = widget.caretDragOffset?.value; + if (caretDragOffset != null) { + // The user is dragging the caret. Snap it to the y-axis but let the user + // move it freely on the x-axis. + caretRect = Offset(caretDragOffset.dx, snapedCaretRect.top) & snapedCaretRect.size; + final distance = caretRect.topLeft - snapedCaretRect.topLeft; + // TODO: should we create a policy for this distance instead of using FloatingCursorPolicies? + final isNearText = distance.dx.abs() <= FloatingCursorPolicies.maximumDistanceToBeNearText; + if (!isNearText) { + ghostCaretRect = snapedCaretRect; + } + } + + if (_caretReleaseAnimationController.isAnimating && _latestCaretDragOffset != null) { + // The caret is animating between the release position and the selected position. + // Interpolate the current offset. + final caretReleaseOffset = Offset(_latestCaretDragOffset!.dx, snapedCaretRect.top); + final caretDestinationOffset = snapedCaretRect.topLeft; + caretRect = Offset.lerp( + caretReleaseOffset, + caretDestinationOffset, + Curves.easeInOut.transform(_caretReleaseAnimationController.value), + )! & + snapedCaretRect.size; + } + return DocumentSelectionLayout( caret: caretRect, + ghostCaret: ghostCaretRect, ); } else { return DocumentSelectionLayout( @@ -790,6 +876,8 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState