diff --git a/lib/src/models/config/raw_editor/raw_editor_configurations.dart b/lib/src/models/config/raw_editor/raw_editor_configurations.dart index b774b9a2a..46fbe2134 100644 --- a/lib/src/models/config/raw_editor/raw_editor_configurations.dart +++ b/lib/src/models/config/raw_editor/raw_editor_configurations.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart' AdaptiveTextSelectionToolbar, PointerDownEvent, TextCapitalization, - TextInputAction; + TextInputAction, + TextMagnifierConfiguration; import 'package:flutter/widgets.dart' show Action, @@ -86,6 +87,7 @@ class QuillRawEditorConfigurations extends Equatable { this.onScribbleActivated, this.scribbleAreaInsets, this.readOnlyMouseCursor = SystemMouseCursors.text, + this.magnifierConfiguration, }); /// Controls the document being edited. @@ -334,6 +336,8 @@ class QuillRawEditorConfigurations extends Equatable { /// Optional insets for the scribble area. final EdgeInsets? scribbleAreaInsets; + final TextMagnifierConfiguration? magnifierConfiguration; + @override List get props => [ readOnly, diff --git a/lib/src/widgets/editor/editor.dart b/lib/src/widgets/editor/editor.dart index a56359677..e74d28728 100644 --- a/lib/src/widgets/editor/editor.dart +++ b/lib/src/widgets/editor/editor.dart @@ -2,7 +2,8 @@ import 'dart:math' as math; import 'package:flutter/cupertino.dart' show CupertinoTheme, cupertinoTextSelectionControls; -import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/foundation.dart' + show ValueListenable, defaultTargetPlatform; import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -292,6 +293,7 @@ class QuillEditorState extends State onScribbleActivated: configurations.onScribbleActivated, scribbleAreaInsets: configurations.scribbleAreaInsets, readOnlyMouseCursor: configurations.readOnlyMouseCursor, + magnifierConfiguration: configurations.magnifierConfiguration, ), ), ), @@ -418,6 +420,7 @@ class _QuillEditorSelectionGestureDetectorBuilder SelectionChangedCause.longPress, ); } + editor?.updateMagnifier(details.globalPosition); } bool _isPositionSelected(TapUpDetails details) { @@ -557,6 +560,8 @@ class _QuillEditorSelectionGestureDetectorBuilder Feedback.forLongPress(_state.context); } } + + _showMagnifierIfSupportedByPlatform(details.globalPosition); } @override @@ -575,8 +580,27 @@ class _QuillEditorSelectionGestureDetectorBuilder } } } + _hideMagnifierIfSupportedByPlatform(); super.onSingleLongTapEnd(details); } + + void _showMagnifierIfSupportedByPlatform(Offset positionToShow) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editor?.showMagnifier(positionToShow); + default: + } + } + + void _hideMagnifierIfSupportedByPlatform() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editor?.hideMagnifier(); + default: + } + } } /// Signature for the callback that reports when the user changes the selection diff --git a/lib/src/widgets/others/text_selection.dart b/lib/src/widgets/others/text_selection.dart index ab2908191..d66ff8f02 100644 --- a/lib/src/widgets/others/text_selection.dart +++ b/lib/src/widgets/others/text_selection.dart @@ -75,6 +75,7 @@ class EditorTextSelectionOverlay { this.onSelectionHandleTapped, this.dragStartBehavior = DragStartBehavior.start, this.handlesVisible = false, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, }) { // Clipboard status is only checked on first instance of // ClipboardStatusNotifier @@ -183,6 +184,13 @@ class EditorTextSelectionOverlay { TextSelection get _selection => value.selection; + final MagnifierController _magnifierController = MagnifierController(); + + final TextMagnifierConfiguration magnifierConfiguration; + + final ValueNotifier _magnifierInfo = + ValueNotifier(MagnifierInfo.empty); + void setHandlesVisible(bool visible) { if (handlesVisible == visible) { return; @@ -237,7 +245,7 @@ class EditorTextSelectionOverlay { BuildContext context, _TextSelectionHandlePosition position) { if (_selection.isCollapsed && position == _TextSelectionHandlePosition.end) { - return Container(); + return const SizedBox.shrink(); } return Visibility( visible: handlesVisible, @@ -252,6 +260,9 @@ class EditorTextSelectionOverlay { selection: _selection, selectionControls: selectionCtrls, position: position, + onHandleDragStart: _onHandleDragStart, + onHandleDragUpdate: _onHandleDragUpdate, + onHandleDragEnd: _onHandleDragEnd, dragStartBehavior: dragStartBehavior, )); } @@ -341,11 +352,12 @@ class EditorTextSelectionOverlay { /// Final cleanup. void dispose() { hide(); + _magnifierInfo.dispose(); } /// Builds the handles by inserting them into the [context]'s overlay. void showHandles() { - assert(_handles == null); + if (_handles != null) return; _handles = [ OverlayEntry( builder: (context) => @@ -366,8 +378,123 @@ class EditorTextSelectionOverlay { void updateForScroll() { markNeedsBuild(); } + + void _onHandleDragStart(DragStartDetails details, TextPosition position) { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) return; + showMagnifier(position, details.globalPosition, renderObject); + } + + void _onHandleDragUpdate(DragUpdateDetails details, TextPosition position) { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) return; + updateMagnifier(position, details.globalPosition, renderObject); + } + + void _onHandleDragEnd(DragEndDetails details) { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) return; + hideMagnifier(); + } + + void showMagnifier( + TextPosition position, Offset offset, RenderEditor editor) { + _showMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: offset, + renderEditable: editor, + ), + ); + } + + void _showMagnifier(MagnifierInfo initialMagnifierInfo) { + // 隐藏toolbar + if (toolbar != null) { + hideToolbar(); + } + // 更新 magnifierInfo + _magnifierInfo.value = initialMagnifierInfo; + + final builtMagnifier = magnifierConfiguration.magnifierBuilder( + context, + _magnifierController, + _magnifierInfo, + ); + + if (builtMagnifier == null) return; + + _magnifierController.show( + context: context, + below: magnifierConfiguration.shouldDisplayHandlesInMagnifier + ? null + : _handles![0], + builder: (_) => builtMagnifier, + ); + } + + void updateMagnifier( + TextPosition position, Offset offset, RenderEditor editor) { + _updateMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: offset, + renderEditable: editor, + ), + ); + } + + void _updateMagnifier(MagnifierInfo magnifierInfo) { + if (_magnifierController.overlayEntry == null) { + return; + } + _magnifierInfo.value = magnifierInfo; + } + + void hideMagnifier() { + if (_magnifierController.overlayEntry == null) { + return; + } + _magnifierController.hide(); + } + + // build magnifier info + MagnifierInfo _buildMagnifier( + {required RenderEditor renderEditable, + required Offset globalGesturePosition, + required TextPosition currentTextPosition}) { + final globalRenderEditableTopLeft = + renderEditable.localToGlobal(Offset.zero); + final localCaretRect = + renderEditable.getLocalRectForCaret(currentTextPosition); + + final lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition); + final positionAtEndOfLine = TextPosition( + offset: lineAtOffset.extentOffset, + affinity: TextAffinity.upstream, + ); + + // Default affinity is downstream. + final positionAtBeginningOfLine = TextPosition( + offset: lineAtOffset.baseOffset, + ); + + final lineBoundaries = Rect.fromPoints( + renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter, + renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter, + ); + + return MagnifierInfo( + fieldBounds: globalRenderEditableTopLeft & renderEditable.size, + globalGesturePosition: globalGesturePosition, + caretRect: localCaretRect.shift(globalRenderEditableTopLeft), + currentLineBoundaries: lineBoundaries.shift(globalRenderEditableTopLeft), + ); + } } +typedef DargHandleCallback = void Function(T details, TextPosition position); + /// This widget represents a single draggable text selection handle. class _TextSelectionHandleOverlay extends StatefulWidget { const _TextSelectionHandleOverlay({ @@ -379,6 +506,9 @@ class _TextSelectionHandleOverlay extends StatefulWidget { required this.onSelectionHandleChanged, required this.onSelectionHandleTapped, required this.selectionControls, + required this.onHandleDragStart, + required this.onHandleDragUpdate, + required this.onHandleDragEnd, this.dragStartBehavior = DragStartBehavior.start, }); @@ -388,6 +518,9 @@ class _TextSelectionHandleOverlay extends StatefulWidget { final LayerLink endHandleLayerLink; final RenderEditor renderObject; final ValueChanged onSelectionHandleChanged; + final DargHandleCallback? onHandleDragStart; + final DargHandleCallback? onHandleDragUpdate; + final ValueChanged onHandleDragEnd; final VoidCallback? onSelectionHandleTapped; final TextSelectionControls selectionControls; final DragStartBehavior dragStartBehavior; @@ -453,15 +586,18 @@ class _TextSelectionHandleOverlayState } void _handleDragStart(DragStartDetails details) { + if (!widget.renderObject.attached) return; final textPosition = widget.position == _TextSelectionHandlePosition.start ? widget.selection.base : widget.selection.extent; final lineHeight = widget.renderObject.preferredLineHeight(textPosition); final handleSize = widget.selectionControls.getHandleSize(lineHeight); _dragPosition = details.globalPosition + Offset(0, -handleSize.height); + widget.onHandleDragStart?.call(details, textPosition); } void _handleDragUpdate(DragUpdateDetails details) { + if (!widget.renderObject.attached) return; _dragPosition += details.delta; final position = widget.renderObject.getPositionForOffset(details.globalPosition); @@ -497,8 +633,17 @@ class _TextSelectionHandleOverlayState if (newSelection.baseOffset >= newSelection.extentOffset) { return; // don't allow order swapping. } - widget.onSelectionHandleChanged(newSelection); + if (widget.position == _TextSelectionHandlePosition.start) { + widget.onHandleDragUpdate?.call(details, newSelection.base); + } else if (widget.position == _TextSelectionHandlePosition.end) { + widget.onHandleDragUpdate?.call(details, newSelection.extent); + } + } + + void _handleDragEnd(DragEndDetails details) { + if (!widget.renderObject.attached) return; + widget.onHandleDragEnd.call(details); } void _handleTap() { @@ -579,6 +724,7 @@ class _TextSelectionHandleOverlayState dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, + onPanEnd: _handleDragEnd, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( diff --git a/lib/src/widgets/quill/text_line.dart b/lib/src/widgets/quill/text_line.dart index e03e5ae8a..553e5fc89 100644 --- a/lib/src/widgets/quill/text_line.dart +++ b/lib/src/widgets/quill/text_line.dart @@ -839,6 +839,10 @@ class RenderEditableTextLine extends RenderEditableBox { _getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1)) .where((element) => element.top < lineDy && element.bottom > lineDy) .toList(growable: false); + if (lineBoxes.isEmpty) { + // Empty line, line box is empty + return TextRange.collapsed(position.offset); + } return TextRange( start: getPositionForOffset( Offset(lineBoxes.first.left, lineDy), diff --git a/lib/src/widgets/raw_editor/raw_editor.dart b/lib/src/widgets/raw_editor/raw_editor.dart index b69b75154..65d095b10 100644 --- a/lib/src/widgets/raw_editor/raw_editor.dart +++ b/lib/src/widgets/raw_editor/raw_editor.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart' show AnimationController, @@ -84,4 +86,10 @@ abstract class EditorState extends State bool showToolbar(); void requestKeyboard(); + + void showMagnifier(Offset positionToShow); + + void updateMagnifier(Offset positionToShow); + + void hideMagnifier(); } diff --git a/lib/src/widgets/raw_editor/raw_editor_state.dart b/lib/src/widgets/raw_editor/raw_editor_state.dart index 07e5f0131..9920bf098 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state.dart @@ -884,7 +884,13 @@ class QuillRawEditorState extends EditorState final oldSelection = controller.selection; controller.updateSelection(selection, ChangeSource.local); + if (_selectionOverlay == null) { + _selectionOverlay = _createSelectionOverlay(); + } else { + _selectionOverlay!.update(textEditingValue); + } _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + _selectionOverlay?.showHandles(); if (!_keyboardVisible) { // This will show the keyboard for all selection changes on the @@ -1245,6 +1251,7 @@ class QuillRawEditorState extends EditorState @override void dispose() { + hideMagnifier(); closeConnectionIfNeeded(); _keyboardVisibilitySubscription?.cancel(); HardwareKeyboard.instance.removeHandler(_hardwareKeyboardEvent); @@ -1340,33 +1347,36 @@ class QuillRawEditorState extends EditorState void _updateOrDisposeSelectionOverlayIfNeeded() { if (_selectionOverlay != null) { - if (!_hasFocus || textEditingValue.selection.isCollapsed) { - _selectionOverlay!.dispose(); - _selectionOverlay = null; - } else { + if (_hasFocus) { _selectionOverlay!.update(textEditingValue); } } else if (_hasFocus) { - _selectionOverlay = EditorTextSelectionOverlay( - value: textEditingValue, - context: context, - debugRequiredFor: widget, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - renderObject: renderEditor, - selectionCtrls: widget.configurations.selectionCtrls, - selectionDelegate: this, - clipboardStatus: _clipboardStatus, - contextMenuBuilder: widget.configurations.contextMenuBuilder == null - ? null - : (context) => - widget.configurations.contextMenuBuilder!(context, this), - ); + _selectionOverlay = _createSelectionOverlay(); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); } } + EditorTextSelectionOverlay _createSelectionOverlay() { + return EditorTextSelectionOverlay( + value: textEditingValue, + context: context, + debugRequiredFor: widget, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditor, + selectionCtrls: widget.configurations.selectionCtrls, + selectionDelegate: this, + clipboardStatus: _clipboardStatus, + contextMenuBuilder: widget.configurations.contextMenuBuilder == null + ? null + : (context) => + widget.configurations.contextMenuBuilder!(context, this), + magnifierConfiguration: widget.configurations.magnifierConfiguration ?? + TextMagnifier.adaptiveMagnifierConfiguration, + ); + } + void _handleFocusChanged() { if (dirty) { requestKeyboard(); @@ -1734,4 +1744,24 @@ class QuillRawEditorState extends EditorState @override bool get shareEnabled => false; + + @override + void hideMagnifier() { + if (_selectionOverlay == null) return; + _selectionOverlay?.hideMagnifier(); + } + + @override + void showMagnifier(ui.Offset positionToShow) { + if (_selectionOverlay == null) return; + final position = renderEditor.getPositionForOffset(positionToShow); + _selectionOverlay?.showMagnifier(position, positionToShow, renderEditor); + } + + @override + void updateMagnifier(ui.Offset positionToShow) { + _updateOrDisposeSelectionOverlayIfNeeded(); + final position = renderEditor.getPositionForOffset(positionToShow); + _selectionOverlay?.updateMagnifier(position, positionToShow, renderEditor); + } }