diff --git a/example/lib/pages/mobile_editor.dart b/example/lib/pages/mobile_editor.dart index 3edf78ccd..803b9db35 100644 --- a/example/lib/pages/mobile_editor.dart +++ b/example/lib/pages/mobile_editor.dart @@ -43,6 +43,8 @@ class _MobileEditorState extends State { editorStyle = _buildMobileEditorStyle(); blockComponentBuilders = _buildBlockComponentBuilders(); + + editorState.debugInfo.debugPaintSizeEnabled = true; } @override @@ -125,7 +127,9 @@ class _MobileEditorState extends State { ), padding: const EdgeInsets.symmetric(horizontal: 24.0), magnifierSize: const Size(144, 96), - mobileDragHandleBallSize: const Size(12, 12), + mobileDragHandleBallSize: const Size.square(8), + mobileDragHandleLeftExtend: 12.0, + mobileDragHandleWidthExtend: 24.0, ); } diff --git a/lib/src/core/transform/transaction.dart b/lib/src/core/transform/transaction.dart index 7eb1d9434..e3a86ff15 100644 --- a/lib/src/core/transform/transaction.dart +++ b/lib/src/core/transform/transaction.dart @@ -36,6 +36,12 @@ class Transaction { /// The before selection is to be recovered if needed. Selection? beforeSelection; + /// The custom selection type is to be applied. + SelectionType? customSelectionType; + + /// The custom selection reason is to be applied. + SelectionUpdateReason? reason; + Map? selectionExtraInfo; // mark needs to be composed diff --git a/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart b/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart index e365d8d80..2df0b64a0 100644 --- a/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart +++ b/lib/src/editor/editor_component/service/ime/delta_input_on_non_text_update_impl.dart @@ -59,7 +59,7 @@ Future onNonTextUpdate( // for the another keyboards (e.g. system keyboard), they will trigger the // `onFloatingCursor` event instead. AppFlowyEditorLog.input.debug('[Android] onNonTextUpdate: $nonTextUpdate'); - if (selection != null) { + if (selection != null && selection != editorState.selection) { editorState.updateSelectionWithReason( Selection.collapsed( Position( @@ -67,6 +67,7 @@ Future onNonTextUpdate( offset: nonTextUpdate.selection.start, ), ), + reason: SelectionUpdateReason.uiEvent, ); } } else if (PlatformExtension.isIOS) { diff --git a/lib/src/editor/editor_component/service/ime/delta_input_service.dart b/lib/src/editor/editor_component/service/ime/delta_input_service.dart index 3cb647fc2..45e46e616 100644 --- a/lib/src/editor/editor_component/service/ime/delta_input_service.dart +++ b/lib/src/editor/editor_component/service/ime/delta_input_service.dart @@ -92,6 +92,11 @@ class DeltaTextInputService extends TextInputService with DeltaTextInputClient { ..setCaretRect(rect); } + @override + void clearComposingTextRange() { + composingTextRange = TextRange.empty; + } + @override void connectionClosed() {} diff --git a/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart b/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart index 24a929b96..818b741d2 100644 --- a/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart +++ b/lib/src/editor/editor_component/service/ime/non_delta_input_service.dart @@ -144,6 +144,11 @@ class NonDeltaTextInputService extends TextInputService with TextInputClient { ..setComposingRect(rect.translate(0, rect.height)); } + @override + void clearComposingTextRange() { + composingTextRange = TextRange.empty; + } + @override void connectionClosed() {} diff --git a/lib/src/editor/editor_component/service/ime/text_input_service.dart b/lib/src/editor/editor_component/service/ime/text_input_service.dart index 6ef0877be..b36dd7efa 100644 --- a/lib/src/editor/editor_component/service/ime/text_input_service.dart +++ b/lib/src/editor/editor_component/service/ime/text_input_service.dart @@ -25,6 +25,8 @@ abstract class TextInputService { TextRange? get composingTextRange; bool get attached; + void clearComposingTextRange(); + void updateCaretPosition(Size size, Matrix4 transform, Rect rect); /// Updates the [TextEditingValue] of the text currently being edited. diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index c06c94ecd..135dfd9ec 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/ime/delta_input_on_floating_cursor_update.dart'; +import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -216,6 +217,9 @@ class KeyboardServiceWidgetState extends State void _attachTextInputService(Selection selection) { final textEditingValue = _getCurrentTextEditingValue(selection); + AppFlowyEditorLog.editor.debug( + 'keyboard service - attach text input service: $textEditingValue', + ); if (textEditingValue != null) { textInputService.attach( textEditingValue, @@ -244,6 +248,16 @@ class KeyboardServiceWidgetState extends State .getNodesInSelection(selection) .where((element) => element.delta != null); + // if the selection is inline and the selection is updated by ui event, + // we should clear the composing range on Android. + final shouldClearComposingRange = + editorState.selectionType == SelectionType.inline && + editorState.selectionUpdateReason == SelectionUpdateReason.uiEvent; + + if (PlatformExtension.isAndroid && shouldClearComposingRange) { + textInputService.clearComposingTextRange(); + } + // Get the composing text range. final composingTextRange = textInputService.composingTextRange ?? TextRange.empty; diff --git a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart index 8902be69c..084521002 100644 --- a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart @@ -99,6 +99,10 @@ class _MobileSelectionServiceWidgetState listen: false, ); + bool isCollapsedHandleVisible = false; + + Timer? collapsedHandleTimer; + @override void initState() { super.initState(); @@ -113,6 +117,7 @@ class _MobileSelectionServiceWidgetState WidgetsBinding.instance.removeObserver(this); selectionNotifierAfterLayout.dispose(); editorState.selectionNotifier.removeListener(_updateSelection); + collapsedHandleTimer?.cancel(); super.dispose(); } @@ -178,13 +183,15 @@ class _MobileSelectionServiceWidgetState valueListenable: selectionNotifierAfterLayout, builder: (context, selection, _) { if (selection == null || !selection.isCollapsed) { + isCollapsedHandleVisible = false; return const SizedBox.shrink(); } - // on iOS, the drag handle should be updated when typing text. + // on Android, the drag handle should be updated when typing text. if (PlatformExtension.isAndroid && editorState.selectionUpdateReason != SelectionUpdateReason.uiEvent) { + isCollapsedHandleVisible = false; return const SizedBox.shrink(); } @@ -193,6 +200,7 @@ class _MobileSelectionServiceWidgetState MobileSelectionDragMode.leftSelectionHandle, MobileSelectionDragMode.rightSelectionHandle, ].contains(dragMode)) { + isCollapsedHandleVisible = false; return const SizedBox.shrink(); } @@ -206,9 +214,14 @@ class _MobileSelectionServiceWidgetState ); if (node == null || rect == null) { + isCollapsedHandleVisible = false; return const SizedBox.shrink(); } + isCollapsedHandleVisible = true; + + _clearCollapsedHandleOnAndroid(); + final editorStyle = editorState.editorStyle; return MobileCollapsedHandle( layerLink: node.layerLink, @@ -218,6 +231,14 @@ class _MobileSelectionServiceWidgetState handleBallWidth: editorStyle.mobileDragHandleBallSize.width, enableHapticFeedbackOnAndroid: editorStyle.enableHapticFeedbackOnAndroid, + onDragging: (isDragging) { + if (isDragging) { + collapsedHandleTimer?.cancel(); + collapsedHandleTimer = null; + } else { + _clearCollapsedHandleOnAndroid(); + } + }, ); }, ); @@ -301,6 +322,25 @@ class _MobileSelectionServiceWidgetState ); } + // The collapsed handle will be dismissed when no user interaction is detected. + void _clearCollapsedHandleOnAndroid() { + if (!PlatformExtension.isAndroid) { + return; + } + collapsedHandleTimer?.cancel(); + collapsedHandleTimer = Timer( + editorState.editorStyle.autoDismissCollapsedHandleDuration, + () { + if (isCollapsedHandleVisible) { + editorState.updateSelectionWithReason( + editorState.selection, + reason: SelectionUpdateReason.transaction, + ); + } + }, + ); + } + @override void updateSelection(Selection? selection) { if (currentSelection.value == selection) { diff --git a/lib/src/editor/editor_component/style/editor_style.dart b/lib/src/editor/editor_component/style/editor_style.dart index adaf3362d..f3295a363 100644 --- a/lib/src/editor/editor_component/style/editor_style.dart +++ b/lib/src/editor/editor_component/style/editor_style.dart @@ -22,6 +22,11 @@ class EditorStyle { this.enableHapticFeedbackOnAndroid = true, this.textScaleFactor = 1.0, this.maxWidth, + this.mobileDragHandleTopExtend, + this.mobileDragHandleWidthExtend, + this.mobileDragHandleLeftExtend, + this.mobileDragHandleHeightExtend, + this.autoDismissCollapsedHandleDuration = const Duration(seconds: 3), }); // The padding of the editor. @@ -69,6 +74,25 @@ class EditorStyle { // Only works on mobile. final Size mobileDragHandleBallSize; + /// The extend of the mobile drag handle. + /// + /// By default, the hit test area of drag handle is the ball size. + /// If you want to extend the hit test area, you can set this value. + /// + /// For example, if you set this value to 10, the hit test area of drag handle + /// will be the ball size + 10 * 2. + final double? mobileDragHandleTopExtend; + final double? mobileDragHandleLeftExtend; + final double? mobileDragHandleWidthExtend; + final double? mobileDragHandleHeightExtend; + + /// The auto-dismiss time of the collapsed handle. + /// + /// The collapsed handle will be dismissed when no user interaction is detected. + /// + /// Only works on Android. + final Duration autoDismissCollapsedHandleDuration; + final double mobileDragHandleWidth; // only works on android @@ -101,7 +125,12 @@ class EditorStyle { mobileDragHandleBallSize = Size.zero, mobileDragHandleWidth = 0.0, enableHapticFeedbackOnAndroid = false, - dragHandleColor = Colors.transparent; + dragHandleColor = Colors.transparent, + mobileDragHandleTopExtend = null, + mobileDragHandleWidthExtend = null, + mobileDragHandleLeftExtend = null, + mobileDragHandleHeightExtend = null, + autoDismissCollapsedHandleDuration = const Duration(seconds: 0); const EditorStyle.mobile({ EdgeInsets? padding, @@ -118,6 +147,11 @@ class EditorStyle { this.enableHapticFeedbackOnAndroid = true, this.textScaleFactor = 1.0, this.maxWidth, + this.mobileDragHandleTopExtend, + this.mobileDragHandleWidthExtend, + this.mobileDragHandleLeftExtend, + this.mobileDragHandleHeightExtend, + this.autoDismissCollapsedHandleDuration = const Duration(seconds: 3), }) : padding = padding ?? const EdgeInsets.symmetric(horizontal: 20), cursorColor = cursorColor ?? const Color(0xFF00BCF0), dragHandleColor = dragHandleColor ?? const Color(0xFF00BCF0), @@ -145,6 +179,11 @@ class EditorStyle { double? cursorWidth, double? textScaleFactor, double? maxWidth, + double? mobileDragHandleTopExtend, + double? mobileDragHandleWidthExtend, + double? mobileDragHandleLeftExtend, + double? mobileDragHandleHeightExtend, + Duration? autoDismissCollapsedHandleDuration, }) { return EditorStyle( padding: padding ?? this.padding, @@ -165,6 +204,16 @@ class EditorStyle { cursorWidth: cursorWidth ?? this.cursorWidth, textScaleFactor: textScaleFactor ?? this.textScaleFactor, maxWidth: maxWidth ?? this.maxWidth, + mobileDragHandleTopExtend: + mobileDragHandleTopExtend ?? this.mobileDragHandleTopExtend, + mobileDragHandleWidthExtend: + mobileDragHandleWidthExtend ?? this.mobileDragHandleWidthExtend, + mobileDragHandleLeftExtend: + mobileDragHandleLeftExtend ?? this.mobileDragHandleLeftExtend, + mobileDragHandleHeightExtend: + mobileDragHandleHeightExtend ?? this.mobileDragHandleHeightExtend, + autoDismissCollapsedHandleDuration: autoDismissCollapsedHandleDuration ?? + this.autoDismissCollapsedHandleDuration, ); } } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index f36afe462..ba33bbc0a 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -14,6 +14,17 @@ typedef EditorTransactionValue = ( ApplyOptions options, ); +class EditorStateDebugInfo { + EditorStateDebugInfo({ + this.debugPaintSizeEnabled = false, + }); + + /// Enable the debug paint size for selection handle. + /// + /// It only available on mobile. + bool debugPaintSizeEnabled; +} + /// the type of this value is bool. /// /// set true to this key to prevent attaching the text service when selection is changed. @@ -166,6 +177,11 @@ class EditorState { service.rendererService = value; } + /// Customize the debug info for the editor state. + /// + /// Refer to [EditorStateDebugInfo] for more details. + EditorStateDebugInfo debugInfo = EditorStateDebugInfo(); + /// store the auto scroller instance in here temporarily. AutoScroller? autoScroller; ScrollableState? scrollableState; @@ -360,7 +376,9 @@ class EditorState { _recordRedoOrUndo(options, transaction, skipHistoryDebounce); if (withUpdateSelection) { - _selectionUpdateReason = SelectionUpdateReason.transaction; + _selectionUpdateReason = + transaction.reason ?? SelectionUpdateReason.transaction; + _selectionType = transaction.customSelectionType; if (transaction.selectionExtraInfo != null) { selectionExtraInfo = transaction.selectionExtraInfo; } diff --git a/lib/src/render/selection/mobile_basic_handle.dart b/lib/src/render/selection/mobile_basic_handle.dart index 7b52b5409..b19d67189 100644 --- a/lib/src/render/selection/mobile_basic_handle.dart +++ b/lib/src/render/selection/mobile_basic_handle.dart @@ -66,6 +66,7 @@ abstract class _IDragHandle extends StatelessWidget { this.handleWidth = 2.0, this.handleBallWidth = 6.0, this.debugPaintSizeEnabled = false, + this.onDragging, required this.handleType, }); @@ -75,6 +76,7 @@ abstract class _IDragHandle extends StatelessWidget { final double handleBallWidth; final HandleType handleType; final bool debugPaintSizeEnabled; + final ValueChanged? onDragging; } class DragHandle extends _IDragHandle { @@ -86,6 +88,7 @@ class DragHandle extends _IDragHandle { super.handleBallWidth, required super.handleType, super.debugPaintSizeEnabled, + super.onDragging, }); @override @@ -100,6 +103,7 @@ class DragHandle extends _IDragHandle { handleBallWidth: handleBallWidth, handleType: handleType, debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, ); } else if (PlatformExtension.isAndroid) { child = _AndroidDragHandle( @@ -109,6 +113,7 @@ class DragHandle extends _IDragHandle { handleBallWidth: handleBallWidth, handleType: handleType, debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, ); } else { throw UnsupportedError('Unsupported platform'); @@ -152,6 +157,7 @@ class _IOSDragHandle extends _IDragHandle { super.handleBallWidth, required super.handleType, super.debugPaintSizeEnabled, + super.onDragging, }); @override @@ -222,18 +228,21 @@ class _IOSDragHandle extends _IDragHandle { details.translate(0, offset), handleType.dragMode, ); + onDragging?.call(true); }, onPanUpdate: (details) { editorState.service.selectionService.onPanUpdate( details.translate(0, offset), handleType.dragMode, ); + onDragging?.call(true); }, onPanEnd: (details) { editorState.service.selectionService.onPanEnd( details, handleType.dragMode, ); + onDragging?.call(false); }, child: child, ); @@ -251,6 +260,7 @@ class _AndroidDragHandle extends _IDragHandle { super.handleBallWidth, required super.handleType, super.debugPaintSizeEnabled, + super.onDragging, }); Selection? selection; @@ -327,6 +337,7 @@ class _AndroidDragHandle extends _IDragHandle { details.translate(0, -ballWidth), handleType.dragMode, ); + onDragging?.call(true); }, onPanUpdate: (details) { final selection = editorState.service.selectionService.onPanUpdate( @@ -337,12 +348,14 @@ class _AndroidDragHandle extends _IDragHandle { HapticFeedback.selectionClick(); } this.selection = selection; + onDragging?.call(true); }, onPanEnd: (details) { editorState.service.selectionService.onPanEnd( details, handleType.dragMode, ); + onDragging?.call(false); }, child: child, ); diff --git a/lib/src/render/selection/mobile_collapsed_handle.dart b/lib/src/render/selection/mobile_collapsed_handle.dart index 77d852c28..98979f54f 100644 --- a/lib/src/render/selection/mobile_collapsed_handle.dart +++ b/lib/src/render/selection/mobile_collapsed_handle.dart @@ -1,8 +1,10 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'package:appflowy_editor/src/render/selection/mobile_basic_handle.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; -class MobileCollapsedHandle extends StatelessWidget { +class MobileCollapsedHandle extends StatefulWidget { const MobileCollapsedHandle({ super.key, required this.layerLink, @@ -11,6 +13,7 @@ class MobileCollapsedHandle extends StatelessWidget { this.handleBallWidth = 6.0, this.handleWidth = 2.0, this.enableHapticFeedbackOnAndroid = true, + this.onDragging, }); final Rect rect; @@ -19,23 +22,33 @@ class MobileCollapsedHandle extends StatelessWidget { final double handleWidth; final double handleBallWidth; final bool enableHapticFeedbackOnAndroid; + final ValueChanged? onDragging; + @override + State createState() => _MobileCollapsedHandleState(); +} + +class _MobileCollapsedHandleState extends State { @override Widget build(BuildContext context) { + final debugInfo = context.read().debugInfo; if (PlatformExtension.isIOS) { return _IOSCollapsedHandle( - layerLink: layerLink, - rect: rect, - handleWidth: handleWidth, + layerLink: widget.layerLink, + rect: widget.rect, + handleWidth: widget.handleWidth, + debugPaintSizeEnabled: debugInfo.debugPaintSizeEnabled, ); } else if (PlatformExtension.isAndroid) { return _AndroidCollapsedHandle( - layerLink: layerLink, - rect: rect, - handleColor: handleColor, - handleWidth: handleWidth, - handleBallWidth: handleBallWidth, - enableHapticFeedbackOnAndroid: enableHapticFeedbackOnAndroid, + layerLink: widget.layerLink, + rect: widget.rect, + handleColor: widget.handleColor, + handleWidth: widget.handleWidth, + handleBallWidth: widget.handleBallWidth, + enableHapticFeedbackOnAndroid: widget.enableHapticFeedbackOnAndroid, + debugPaintSizeEnabled: debugInfo.debugPaintSizeEnabled, + onDragging: widget.onDragging, ); } throw UnsupportedError('Unsupported platform'); @@ -47,21 +60,30 @@ class _IOSCollapsedHandle extends StatelessWidget { required this.layerLink, required this.rect, this.handleWidth = 2.0, + this.debugPaintSizeEnabled = false, }); final Rect rect; final LayerLink layerLink; final double handleWidth; + final bool debugPaintSizeEnabled; @override Widget build(BuildContext context) { // Extend the click area to make it easier to click. - const extend = 10.0; + final editorStyle = context.read().editorStyle; + const defaultExtend = 10.0; + final topExtend = editorStyle.mobileDragHandleTopExtend ?? defaultExtend; + final leftExtend = editorStyle.mobileDragHandleLeftExtend ?? defaultExtend; + final widthExtend = + editorStyle.mobileDragHandleWidthExtend ?? 2 * defaultExtend; + final heightExtend = + editorStyle.mobileDragHandleHeightExtend ?? 2 * defaultExtend; final adjustedRect = Rect.fromLTWH( - rect.left - extend, - rect.top - extend, - rect.width + 2 * extend, - rect.height + 2 * extend, + rect.left - leftExtend, + rect.top - topExtend, + rect.width + widthExtend, + rect.height + heightExtend, ); return Positioned.fromRect( rect: adjustedRect, @@ -78,6 +100,7 @@ class _IOSCollapsedHandle extends StatelessWidget { handleType: HandleType.collapsed, handleColor: Colors.transparent, handleWidth: adjustedRect.width, + debugPaintSizeEnabled: debugPaintSizeEnabled, ), ), ], @@ -95,6 +118,8 @@ class _AndroidCollapsedHandle extends StatelessWidget { this.handleBallWidth = 6.0, this.handleWidth = 2.0, this.enableHapticFeedbackOnAndroid = true, + this.debugPaintSizeEnabled = false, + this.onDragging, }); final Rect rect; @@ -103,17 +128,25 @@ class _AndroidCollapsedHandle extends StatelessWidget { final double handleWidth; final double handleBallWidth; final bool enableHapticFeedbackOnAndroid; + final bool debugPaintSizeEnabled; + final ValueChanged? onDragging; @override Widget build(BuildContext context) { // Extend the click area to make it easier to click. + final editorStyle = context.read().editorStyle; + final topExtend = editorStyle.mobileDragHandleTopExtend ?? 0; + final leftExtend = + editorStyle.mobileDragHandleLeftExtend ?? 2 * handleBallWidth; + final widthExtend = + editorStyle.mobileDragHandleWidthExtend ?? 4 * handleBallWidth; + final heightExtend = + editorStyle.mobileDragHandleHeightExtend ?? 2 * handleBallWidth; final adjustedRect = Rect.fromLTWH( - rect.left - 2 * (handleBallWidth), - rect.top, - rect.width + 4 * (handleBallWidth), - // Enable clicking in the handle area outside the stack. - // https://github.com/flutter/flutter/issues/75747 - rect.height + 2 * handleBallWidth, + rect.left - leftExtend, + rect.top - topExtend, + rect.width + widthExtend, + rect.height + heightExtend, ); return Positioned.fromRect( rect: adjustedRect, @@ -132,6 +165,8 @@ class _AndroidCollapsedHandle extends StatelessWidget { handleColor: handleColor, handleWidth: adjustedRect.width, handleBallWidth: handleBallWidth, + debugPaintSizeEnabled: debugPaintSizeEnabled, + onDragging: onDragging, ), ), ],