diff --git a/CHANGELOG.md b/CHANGELOG.md index 314cdcafb..42f943fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Can't select text when `readOnly` is true [#2529](https://github.com/singerdmx/flutter-quill/pull/2529). + +### Added + +- Display magnifier using `RawMagnifier` widget when dragging on iOS/Android [#2529](https://github.com/singerdmx/flutter-quill/pull/2529). + ## [11.2.0] - 2025-03-26 ### Added diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index c11b0eeea..81e19fa9a 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -27,6 +27,7 @@ export 'src/editor/style_widgets/style_widgets.dart'; export 'src/editor/widgets/cursor.dart'; export 'src/editor/widgets/default_styles.dart'; export 'src/editor/widgets/link.dart'; +export 'src/editor/widgets/text/magnifier.dart'; export 'src/editor/widgets/text/utils/text_block_utils.dart'; export 'src/editor_toolbar_controller_shared/copy_cut_service/copy_cut_service.dart'; export 'src/editor_toolbar_controller_shared/copy_cut_service/copy_cut_service_provider.dart'; diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index aa2b5c9d5..c51edafaf 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -14,6 +14,7 @@ import '../raw_editor/raw_editor.dart'; import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; +import '../widgets/text/magnifier.dart'; import '../widgets/text/utils/text_block_utils.dart'; import 'search_config.dart'; @@ -56,6 +57,7 @@ class QuillEditorConfig { this.enableAlwaysIndentOnTab = false, this.embedBuilders, this.textSpanBuilder = defaultSpanBuilder, + this.quillMagnifierBuilder, this.unknownEmbedBuilder, @experimental this.searchConfig = const QuillSearchConfig(), this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, @@ -367,6 +369,14 @@ class QuillEditorConfig { final TextSpanBuilder textSpanBuilder; + /// To add a magnifier when selecting, specify a builder that returns the magnfier widget + /// + /// The default is no magnifier + /// + /// There is a provided magnifier [QuillMagnifier] that is available via the function + /// defaultQuillMagnifierBuilder + final QuillMagnifierBuilder? quillMagnifierBuilder; + /// See [search](https://github.com/singerdmx/flutter-quill/blob/master/doc/configurations/search.md) /// page for docs. @experimental diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 567b31335..117819392 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -196,6 +196,9 @@ class QuillEditorState extends State<QuillEditor> QuillEditorConfig get configurations => widget.config; QuillEditorConfig get config => widget.config; + /// {@macro drag_offset_notifier} + final dragOffsetNotifier = isMobileApp ? ValueNotifier<Offset?>(null) : null; + @override void initState() { super.initState(); @@ -260,6 +263,7 @@ class QuillEditorState extends State<QuillEditor> final child = QuillRawEditor( key: _editorKey, controller: controller, + dragOffsetNotifier: dragOffsetNotifier, config: QuillRawEditorConfig( characterShortcutEvents: widget.config.characterShortcutEvents, spaceShortcutEvents: widget.config.spaceShortcutEvents, @@ -305,6 +309,7 @@ class QuillEditorState extends State<QuillEditor> scrollPhysics: config.scrollPhysics, embedBuilder: _getEmbedBuilder, textSpanBuilder: config.textSpanBuilder, + quillMagnifierBuilder: config.quillMagnifierBuilder, linkActionPickerDelegate: config.linkActionPickerDelegate, customStyleBuilder: config.customStyleBuilder, customRecognizerBuilder: config.customRecognizerBuilder, @@ -330,6 +335,8 @@ class QuillEditorState extends State<QuillEditor> behavior: HitTestBehavior.translucent, detectWordBoundary: config.detectWordBoundary, child: child, + dragOffsetNotifier: dragOffsetNotifier, + quillMagnifierBuilder: config.quillMagnifierBuilder, ) : child; diff --git a/lib/src/editor/raw_editor/config/raw_editor_config.dart b/lib/src/editor/raw_editor/config/raw_editor_config.dart index e76f68f74..f7f9df74a 100644 --- a/lib/src/editor/raw_editor/config/raw_editor_config.dart +++ b/lib/src/editor/raw_editor/config/raw_editor_config.dart @@ -12,6 +12,7 @@ import '../../../editor/widgets/default_styles.dart'; import '../../../editor/widgets/delegate.dart'; import '../../../editor/widgets/link.dart'; import '../../../toolbar/theme/quill_dialog_theme.dart'; +import '../../widgets/text/magnifier.dart'; import '../../widgets/text/utils/text_block_utils.dart'; import '../builders/leading_block_builder.dart'; import 'events/events.dart'; @@ -70,6 +71,7 @@ class QuillRawEditorConfig { this.readOnlyMouseCursor = SystemMouseCursors.text, this.onPerformAction, @experimental this.customLeadingBuilder, + this.quillMagnifierBuilder, }); /// Controls whether this editor has keyboard focus. @@ -408,4 +410,7 @@ class QuillRawEditorConfig { /// Called when a text input action is performed. final void Function(TextInputAction action)? onPerformAction; + + /// Used to build the [QuillMagnifier] when long-pressing/dragging selection + final QuillMagnifierBuilder? quillMagnifierBuilder; } diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index 4cc9543f3..edfaecf35 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -11,6 +11,7 @@ class QuillRawEditor extends StatefulWidget { QuillRawEditor({ required this.config, required this.controller, + this.dragOffsetNotifier, super.key, }) : assert(config.maxHeight == null || config.maxHeight! > 0, 'maxHeight cannot be null'), @@ -25,6 +26,28 @@ class QuillRawEditor extends StatefulWidget { final QuillController controller; final QuillRawEditorConfig config; + /// {@template drag_offset_notifier} + /// dragOffsetNotifier - Only used on iOS and Android + /// + /// [QuillRawEditor] contains a gesture detector [EditorTextSelectionGestureDetector] + /// within it's widget tree that includes a [RawMagnifier]. The RawMagnifier needs + /// the current position of selection drag events in order to display the magnifier + /// in the correct location. Setting the position to null will hide the magnifier. + /// + /// Initial selection events are posted by [EditorTextSelectionGestureDetector]. Once + /// a selection has been created, dragging the selection handles happens in + /// [EditorTextSelectionOverlay]. + /// + /// Both [EditorTextSelectionGestureDetector] and [EditorTextSelectionOverlay] will update + /// the value of the dragOffsetNotifier. + /// + /// The [EditorTextSelectionGestureDetector] will use the value to display the magnifier in + /// the correct location (or hide the magnifier if null). [EditorTextSelectionOverlay] will + /// use the value of the dragOffsetNotifier to hide the context menu when the magnifier is + /// displayed and show the context menu when dragging is complete. + /// {@endtemplate} + final ValueNotifier<Offset?>? dragOffsetNotifier; + @override State<StatefulWidget> createState() => QuillRawEditorState(); } diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index 5c70f5792..98905c2b3 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -853,8 +853,9 @@ class QuillRawEditorState extends EditorState }); } + controller.addListener(_didChangeTextEditingValueListener); + if (!widget.config.readOnly) { - controller.addListener(_didChangeTextEditingValueListener); // listen to composing range changes composingRange.addListener(_onComposingRangeChanged); // Focus @@ -965,8 +966,8 @@ class QuillRawEditorState extends EditorState assert(!hasConnection); _selectionOverlay?.dispose(); _selectionOverlay = null; + controller.removeListener(_didChangeTextEditingValueListener); if (!widget.config.readOnly) { - controller.removeListener(_didChangeTextEditingValueListener); widget.config.focusNode.removeListener(_handleFocusChanged); composingRange.removeListener(_onComposingRangeChanged); } @@ -1081,6 +1082,7 @@ class QuillRawEditorState extends EditorState contextMenuBuilder: widget.config.contextMenuBuilder == null ? null : (context) => widget.config.contextMenuBuilder!(context, this), + dragOffsetNotifier: widget.dragOffsetNotifier, ); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); diff --git a/lib/src/editor/widgets/delegate.dart b/lib/src/editor/widgets/delegate.dart index af6eee257..20216dfa2 100644 --- a/lib/src/editor/widgets/delegate.dart +++ b/lib/src/editor/widgets/delegate.dart @@ -8,6 +8,7 @@ import '../../document/attribute.dart'; import '../../document/nodes/leaf.dart'; import '../editor.dart'; import '../raw_editor/raw_editor.dart'; +import 'text/magnifier.dart'; import 'text/text_selection.dart'; typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); @@ -361,6 +362,8 @@ class EditorTextSelectionGestureDetectorBuilder { required Widget child, Key? key, bool detectWordBoundary = true, + ValueNotifier<Offset?>? dragOffsetNotifier, + QuillMagnifierBuilder? quillMagnifierBuilder, }) { return EditorTextSelectionGestureDetector( key: key, @@ -379,6 +382,8 @@ class EditorTextSelectionGestureDetectorBuilder { onDragSelectionEnd: onDragSelectionEnd, behavior: behavior, detectWordBoundary: detectWordBoundary, + dragOffsetNotifier: dragOffsetNotifier, + quillMagnifierBuilder: quillMagnifierBuilder, child: child, ); } diff --git a/lib/src/editor/widgets/text/magnifier.dart b/lib/src/editor/widgets/text/magnifier.dart new file mode 100644 index 000000000..add2dc2b2 --- /dev/null +++ b/lib/src/editor/widgets/text/magnifier.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +typedef QuillMagnifierBuilder = Widget Function(Offset dragPosition); + +Widget defaultQuillMagnifierBuilder(Offset dragPosition) => + QuillMagnifier(dragPosition: dragPosition); + +class QuillMagnifier extends StatelessWidget { + const QuillMagnifier({required this.dragPosition, super.key}); + + final Offset dragPosition; + + @override + Widget build(BuildContext context) { + final position = dragPosition.translate(-60, -80); + return Positioned( + top: position.dy, + left: position.dx, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + ), + child: RawMagnifier( + clipBehavior: Clip.hardEdge, + decoration: MagnifierDecoration( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shadows: const [ + BoxShadow( + color: Colors.black26, + spreadRadius: 2, + blurRadius: 5, + offset: Offset(3, 3), // changes position of shadow + ), + ], + ), + size: const Size(100, 45), + focalPointOffset: const Offset(5, 55), + magnificationScale: 1.3, + ), + ), + ); + } +} diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index a748e60f5..75517e486 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; import '../../../document/nodes/node.dart'; import '../../editor.dart'; +import 'magnifier.dart'; TextSelection localSelection(Node node, TextSelection selection, fromParent) { final base = fromParent ? node.offset : node.documentOffset; @@ -76,6 +77,7 @@ class EditorTextSelectionOverlay { this.onSelectionHandleTapped, this.dragStartBehavior = DragStartBehavior.start, this.handlesVisible = false, + this.dragOffsetNotifier, }) { // Clipboard status is only checked on first instance of // ClipboardStatusNotifier @@ -93,6 +95,9 @@ class EditorTextSelectionOverlay { TextEditingValue value; + /// The offset of the drag handle used to position the magnifier. + ValueNotifier<Offset?>? dragOffsetNotifier; + /// Whether selection handles are visible. /// /// Set to false if you want to hide the handles. Use this property to show or @@ -214,6 +219,7 @@ class EditorTextSelectionOverlay { /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(toolbar != null); + dragOffsetNotifier?.removeListener(_dragOffsetListener); toolbar!.remove(); toolbar = null; } @@ -222,7 +228,13 @@ class EditorTextSelectionOverlay { void showToolbar() { assert(toolbar == null); if (contextMenuBuilder == null) return; + dragOffsetNotifier?.addListener(_dragOffsetListener); toolbar = OverlayEntry(builder: (context) { + // when the dragOffsetNotifier is not null and the value is not null + // the magnifier is being shown, so we don't want to show the context menu + if (dragOffsetNotifier?.value != null) { + return Container(); + } return contextMenuBuilder!(context); }); Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) @@ -234,6 +246,13 @@ class EditorTextSelectionOverlay { } } + // after dragging and magnifier is removed, restore the context menu + void _dragOffsetListener() { + if (dragOffsetNotifier?.value == null) { + toolbar?.markNeedsBuild(); + } + } + Widget _buildHandle( BuildContext context, _TextSelectionHandlePosition position) { if (_selection.isCollapsed && @@ -254,6 +273,7 @@ class EditorTextSelectionOverlay { selectionControls: selectionCtrls, position: position, dragStartBehavior: dragStartBehavior, + dragOffsetNotifier: dragOffsetNotifier, )); } @@ -379,6 +399,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { required this.onSelectionHandleTapped, required this.selectionControls, this.dragStartBehavior = DragStartBehavior.start, + this.dragOffsetNotifier, }); final TextSelection selection; @@ -390,6 +411,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { final VoidCallback? onSelectionHandleTapped; final TextSelectionControls selectionControls; final DragStartBehavior dragStartBehavior; + final ValueNotifier<Offset?>? dragOffsetNotifier; @override _TextSelectionHandleOverlayState createState() => @@ -450,6 +472,7 @@ class _TextSelectionHandleOverlayState } void _handleDragStart(DragStartDetails details) { + widget.dragOffsetNotifier?.value = details.globalPosition; final textPosition = widget.position == _TextSelectionHandlePosition.start ? widget.selection.base : widget.selection.extent; @@ -458,7 +481,13 @@ class _TextSelectionHandleOverlayState _dragPosition = details.globalPosition + Offset(0, -handleSize.height); } + void _handleDragEnd(DragEndDetails details) { + // when the drag is complete, we need to clear the drag offset + widget.dragOffsetNotifier?.value = null; + } + void _handleDragUpdate(DragUpdateDetails details) { + widget.dragOffsetNotifier?.value = details.globalPosition; _dragPosition += details.delta; final position = widget.renderObject.getPositionForOffset(details.globalPosition); @@ -574,6 +603,7 @@ class _TextSelectionHandleOverlayState dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, + onPanEnd: _handleDragEnd, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( @@ -648,6 +678,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { this.onDragSelectionEnd, this.behavior, this.detectWordBoundary = true, + this.dragOffsetNotifier, + this.quillMagnifierBuilder, super.key, }); @@ -725,6 +757,10 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { final bool detectWordBoundary; + final ValueNotifier<Offset?>? dragOffsetNotifier; + + final QuillMagnifierBuilder? quillMagnifierBuilder; + @override State<StatefulWidget> createState() => _EditorTextSelectionGestureDetectorState(); @@ -743,13 +779,45 @@ class _EditorTextSelectionGestureDetectorState // _isDoubleTap for mouse right click bool _isSecondaryDoubleTap = false; + // The last offset of the drag gesture. + Offset? _magnifierPosition; + + @override + void initState() { + // when the drag offset changes (from handle drag or 1st selection update the magnifier) + widget.dragOffsetNotifier?.addListener(_dragOffsetListener); + super.initState(); + } + @override void dispose() { _doubleTapTimer?.cancel(); _dragUpdateThrottleTimer?.cancel(); + widget.dragOffsetNotifier?.removeListener(_dragOffsetListener); super.dispose(); } + // update magnifier location (hide if null) - this listener is called during a build phase + // when selection handles are being dragged, so update during the next build + void _dragOffsetListener() { + WidgetsBinding.instance.addPostFrameCallback((_) { + Offset? position; + + final globalPosition = widget.dragOffsetNotifier?.value; + + if (globalPosition != null) { + final renderBox = context.findRenderObject()! as RenderBox; + position = renderBox.globalToLocal(globalPosition); + } + + if (mounted) { + setState(() { + _magnifierPosition = position; + }); + } + }); + } + // The down handler is force-run on success of a single tap and optimistically // run before a long press success. void _handleTapDown(TapDownDetails details) { @@ -820,6 +888,7 @@ class _EditorTextSelectionGestureDetectorState void _handleDragStart(DragStartDetails details) { assert(_lastDragStartDetails == null); _lastDragStartDetails = details; + widget.dragOffsetNotifier?.value = details.globalPosition; widget.onDragSelectionStart?.call(details); } @@ -840,6 +909,7 @@ class _EditorTextSelectionGestureDetectorState void _handleDragUpdateThrottled() { assert(_lastDragStartDetails != null); assert(_lastDragUpdateDetails != null); + widget.dragOffsetNotifier?.value = _lastDragUpdateDetails?.globalPosition; if (widget.onDragSelectionUpdate != null) { widget.onDragSelectionUpdate!( //_lastDragStartDetails!, @@ -879,12 +949,14 @@ class _EditorTextSelectionGestureDetectorState void _handleLongPressStart(LongPressStartDetails details) { if (!_isDoubleTap) { + widget.dragOffsetNotifier?.value = details.globalPosition; widget.onSingleLongTapStart?.call(details); } } void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { if (!_isDoubleTap) { + widget.dragOffsetNotifier?.value = details.globalPosition; widget.onSingleLongTapMoveUpdate?.call(details); } } @@ -893,6 +965,9 @@ class _EditorTextSelectionGestureDetectorState if (!_isDoubleTap) { widget.onSingleLongTapEnd?.call(details); } + // after a long press (from double tap or drag) make sure + // magnifier is removed + widget.dragOffsetNotifier?.value = null; _isDoubleTap = false; } @@ -984,7 +1059,15 @@ class _EditorTextSelectionGestureDetectorState gestures: gestures, excludeFromSemantics: true, behavior: widget.behavior, - child: widget.child, + child: (widget.quillMagnifierBuilder == null) + ? widget.child + : Stack( + children: [ + widget.child, + if (_magnifierPosition != null) + widget.quillMagnifierBuilder!(_magnifierPosition!) + ], + ), ); } }