From d90bc6c7cef7a44dc2464a16cb7fa396a688188f Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 1 Apr 2025 16:51:15 -0700 Subject: [PATCH 01/12] enable selection handles in read only mode, added magnifier --- lib/src/editor/editor.dart | 5 + lib/src/editor/raw_editor/raw_editor.dart | 2 + .../editor/raw_editor/raw_editor_state.dart | 6 +- lib/src/editor/widgets/delegate.dart | 2 + .../editor/widgets/text/text_selection.dart | 111 ++++++++++++++++-- 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 567b31335..23e2d22f8 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -196,6 +196,9 @@ class QuillEditorState extends State QuillEditorConfig get configurations => widget.config; QuillEditorConfig get config => widget.config; + // The offset of the drag gesture - where to display the magnifier + final dragOffsetNotifier = isMobileApp ? ValueNotifier(null) : null; + @override void initState() { super.initState(); @@ -260,6 +263,7 @@ class QuillEditorState extends State final child = QuillRawEditor( key: _editorKey, controller: controller, + dragOffsetNotifier: dragOffsetNotifier, config: QuillRawEditorConfig( characterShortcutEvents: widget.config.characterShortcutEvents, spaceShortcutEvents: widget.config.spaceShortcutEvents, @@ -330,6 +334,7 @@ class QuillEditorState extends State behavior: HitTestBehavior.translucent, detectWordBoundary: config.detectWordBoundary, child: child, + dragOffsetNotifier: dragOffsetNotifier, ) : child; diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index 4cc9543f3..e55d3509c 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'), @@ -24,6 +25,7 @@ class QuillRawEditor extends StatefulWidget { final QuillController controller; final QuillRawEditorConfig config; + ValueNotifier? dragOffsetNotifier; @override State 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..90042f986 100644 --- a/lib/src/editor/widgets/delegate.dart +++ b/lib/src/editor/widgets/delegate.dart @@ -361,6 +361,7 @@ class EditorTextSelectionGestureDetectorBuilder { required Widget child, Key? key, bool detectWordBoundary = true, + ValueNotifier? dragOffsetNotifier, }) { return EditorTextSelectionGestureDetector( key: key, @@ -380,6 +381,7 @@ class EditorTextSelectionGestureDetectorBuilder { behavior: behavior, detectWordBoundary: detectWordBoundary, child: child, + dragOffsetNotifier: dragOffsetNotifier, ); } } diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index a748e60f5..f8346a06f 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -76,6 +76,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 +94,9 @@ class EditorTextSelectionOverlay { TextEditingValue value; + /// The offset of the drag handle used to position the magnifier. + ValueNotifier? dragOffsetNotifier; + /// Whether selection handles are visible. /// /// Set to false if you want to hide the handles. Use this property to show or @@ -254,6 +258,7 @@ class EditorTextSelectionOverlay { selectionControls: selectionCtrls, position: position, dragStartBehavior: dragStartBehavior, + dragOffsetNotifier: dragOffsetNotifier, )); } @@ -348,10 +353,10 @@ class EditorTextSelectionOverlay { _handles = [ OverlayEntry( builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.start)), + _buildHandle(context, _TextSelectionHandlePosition.start)), OverlayEntry( builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.end)), + _buildHandle(context, _TextSelectionHandlePosition.end)), ]; Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) @@ -379,6 +384,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { required this.onSelectionHandleTapped, required this.selectionControls, this.dragStartBehavior = DragStartBehavior.start, + this.dragOffsetNotifier, }); final TextSelection selection; @@ -390,6 +396,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { final VoidCallback? onSelectionHandleTapped; final TextSelectionControls selectionControls; final DragStartBehavior dragStartBehavior; + final ValueNotifier? dragOffsetNotifier; @override _TextSelectionHandleOverlayState createState() => @@ -450,6 +457,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,10 +466,16 @@ 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); + widget.renderObject.getPositionForOffset(details.globalPosition); if (widget.selection.isCollapsed) { widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); return; @@ -474,17 +488,17 @@ class _TextSelectionHandleOverlayState case _TextSelectionHandlePosition.start: newSelection = TextSelection( baseOffset: - isNormalized ? position.offset : widget.selection.baseOffset, + isNormalized ? position.offset : widget.selection.baseOffset, extentOffset: - isNormalized ? widget.selection.extentOffset : position.offset, + isNormalized ? widget.selection.extentOffset : position.offset, ); break; case _TextSelectionHandlePosition.end: newSelection = TextSelection( baseOffset: - isNormalized ? widget.selection.baseOffset : position.offset, + isNormalized ? widget.selection.baseOffset : position.offset, extentOffset: - isNormalized ? position.offset : widget.selection.extentOffset, + isNormalized ? position.offset : widget.selection.extentOffset, ); break; } @@ -537,7 +551,7 @@ class _TextSelectionHandleOverlayState : widget.selection.extent; final lineHeight = widget.renderObject.preferredLineHeight(textPosition); final handleAnchor = - widget.selectionControls.getHandleAnchor(type!, lineHeight); + widget.selectionControls.getHandleAnchor(type!, lineHeight); final handleSize = widget.selectionControls.getHandleSize(lineHeight); final handleRect = Rect.fromLTWH( @@ -574,6 +588,7 @@ class _TextSelectionHandleOverlayState dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, + onPanEnd: _handleDragEnd, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( @@ -648,6 +663,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { this.onDragSelectionEnd, this.behavior, this.detectWordBoundary = true, + this.dragOffsetNotifier, super.key, }); @@ -725,6 +741,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { final bool detectWordBoundary; + final ValueNotifier? dragOffsetNotifier; + @override State createState() => _EditorTextSelectionGestureDetectorState(); @@ -743,13 +761,46 @@ class _EditorTextSelectionGestureDetectorState // _isDoubleTap for mouse right click bool _isSecondaryDoubleTap = false; + // The last offset of the drag gesture. + Offset? _magnifierPosition; + + @override + @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 +871,7 @@ class _EditorTextSelectionGestureDetectorState void _handleDragStart(DragStartDetails details) { assert(_lastDragStartDetails == null); _lastDragStartDetails = details; + widget.dragOffsetNotifier?.value = details.globalPosition; widget.onDragSelectionStart?.call(details); } @@ -840,6 +892,7 @@ class _EditorTextSelectionGestureDetectorState void _handleDragUpdateThrottled() { assert(_lastDragStartDetails != null); assert(_lastDragUpdateDetails != null); + widget.dragOffsetNotifier?.value = _lastDragUpdateDetails?.globalPosition; if (widget.onDragSelectionUpdate != null) { widget.onDragSelectionUpdate!( //_lastDragStartDetails!, @@ -879,18 +932,21 @@ 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); } } void _handleLongPressEnd(LongPressEndDetails details) { if (!_isDoubleTap) { + widget.dragOffsetNotifier?.value = null; widget.onSingleLongTapEnd?.call(details); } _isDoubleTap = false; @@ -974,7 +1030,7 @@ class _EditorTextSelectionGestureDetectorState (instance) { instance ..onStart = - widget.onForcePressStart != null ? _forcePressStarted : null + widget.onForcePressStart != null ? _forcePressStarted : null ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; }, ); @@ -984,7 +1040,42 @@ class _EditorTextSelectionGestureDetectorState gestures: gestures, excludeFromSemantics: true, behavior: widget.behavior, - child: widget.child, + child: Stack( + children: [ + widget.child, + if (_magnifierPosition != null) + Builder(builder: (context) { + final position = _magnifierPosition!.translate(-60, -80); + return Positioned( + top: position.dy, + left: position.dx, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).scaffoldBackgroundColor, + ), + 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, + ), + ), + ); + }), + ], + ), ); } } From 7ac0ae8363df09da805b67682b381c7a66e5e525 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Wed, 2 Apr 2025 12:36:27 -0700 Subject: [PATCH 02/12] hide the context menu when dragging selection handles --- lib/src/editor/widgets/text/text_selection.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index f8346a06f..66c8638d5 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -218,6 +218,7 @@ class EditorTextSelectionOverlay { /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(toolbar != null); + dragOffsetNotifier?.removeListener(_dragOffsetListener); toolbar!.remove(); toolbar = null; } @@ -226,7 +227,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) @@ -238,6 +245,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 && From ff883df75bcc2d6b4c16ae9be3870cd3583164ef Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Wed, 2 Apr 2025 16:11:45 -0700 Subject: [PATCH 03/12] remove duplicate line --- lib/src/editor/widgets/text/text_selection.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index 66c8638d5..820bd9933 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -778,7 +778,6 @@ class _EditorTextSelectionGestureDetectorState // The last offset of the drag gesture. Offset? _magnifierPosition; - @override @override void initState() { // when the drag offset changes (from handle drag or 1st selection update the magnifier) From 849279126787883379c4b4412dd493b7afea19b2 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Wed, 2 Apr 2025 16:16:16 -0700 Subject: [PATCH 04/12] added entry to change log file --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 314cdcafb..cf975ca8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Allow selection in read only mode +- Display magnifier when dragging on iOS/Android +- Fixes [#2518](https://github.com/singerdmx/flutter-quill/pull/2518) + ## [11.2.0] - 2025-03-26 ### Added From b92ed48b0c9ad76e651e53fa317d257202231eb2 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Thu, 3 Apr 2025 08:23:27 -0700 Subject: [PATCH 05/12] code clean up --- lib/src/editor/raw_editor/raw_editor.dart | 2 +- lib/src/editor/widgets/delegate.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index e55d3509c..f88b29453 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -25,7 +25,7 @@ class QuillRawEditor extends StatefulWidget { final QuillController controller; final QuillRawEditorConfig config; - ValueNotifier? dragOffsetNotifier; + final ValueNotifier? dragOffsetNotifier; @override State createState() => QuillRawEditorState(); diff --git a/lib/src/editor/widgets/delegate.dart b/lib/src/editor/widgets/delegate.dart index 90042f986..1e0de1fad 100644 --- a/lib/src/editor/widgets/delegate.dart +++ b/lib/src/editor/widgets/delegate.dart @@ -380,8 +380,8 @@ class EditorTextSelectionGestureDetectorBuilder { onDragSelectionEnd: onDragSelectionEnd, behavior: behavior, detectWordBoundary: detectWordBoundary, - child: child, dragOffsetNotifier: dragOffsetNotifier, + child: child, ); } } From 8b55268bb8adb446ca9e363167bd62735eddc25b Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Thu, 3 Apr 2025 08:45:12 -0700 Subject: [PATCH 06/12] after a long press (from double tap or drag) make sure magnifier is removed --- lib/src/editor/widgets/text/text_selection.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index 820bd9933..78ae49f07 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -959,9 +959,11 @@ class _EditorTextSelectionGestureDetectorState void _handleLongPressEnd(LongPressEndDetails details) { if (!_isDoubleTap) { - widget.dragOffsetNotifier?.value = null; widget.onSingleLongTapEnd?.call(details); } + // after a long press (from double tap or drag) make sure + // magnifier is removed + widget.dragOffsetNotifier?.value = null; _isDoubleTap = false; } From b5759d3b99fb921f032a21a214842df4eeb8fc7e Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Thu, 3 Apr 2025 08:54:40 -0700 Subject: [PATCH 07/12] code clean up --- .../editor/widgets/text/text_selection.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index 78ae49f07..65322d0f3 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -367,10 +367,10 @@ class EditorTextSelectionOverlay { _handles = [ OverlayEntry( builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.start)), + _buildHandle(context, _TextSelectionHandlePosition.start)), OverlayEntry( builder: (context) => - _buildHandle(context, _TextSelectionHandlePosition.end)), + _buildHandle(context, _TextSelectionHandlePosition.end)), ]; Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) @@ -489,7 +489,7 @@ class _TextSelectionHandleOverlayState widget.dragOffsetNotifier?.value = details.globalPosition; _dragPosition += details.delta; final position = - widget.renderObject.getPositionForOffset(details.globalPosition); + widget.renderObject.getPositionForOffset(details.globalPosition); if (widget.selection.isCollapsed) { widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); return; @@ -502,17 +502,17 @@ class _TextSelectionHandleOverlayState case _TextSelectionHandlePosition.start: newSelection = TextSelection( baseOffset: - isNormalized ? position.offset : widget.selection.baseOffset, + isNormalized ? position.offset : widget.selection.baseOffset, extentOffset: - isNormalized ? widget.selection.extentOffset : position.offset, + isNormalized ? widget.selection.extentOffset : position.offset, ); break; case _TextSelectionHandlePosition.end: newSelection = TextSelection( baseOffset: - isNormalized ? widget.selection.baseOffset : position.offset, + isNormalized ? widget.selection.baseOffset : position.offset, extentOffset: - isNormalized ? position.offset : widget.selection.extentOffset, + isNormalized ? position.offset : widget.selection.extentOffset, ); break; } @@ -565,7 +565,7 @@ class _TextSelectionHandleOverlayState : widget.selection.extent; final lineHeight = widget.renderObject.preferredLineHeight(textPosition); final handleAnchor = - widget.selectionControls.getHandleAnchor(type!, lineHeight); + widget.selectionControls.getHandleAnchor(type!, lineHeight); final handleSize = widget.selectionControls.getHandleSize(lineHeight); final handleRect = Rect.fromLTWH( @@ -1045,7 +1045,7 @@ class _EditorTextSelectionGestureDetectorState (instance) { instance ..onStart = - widget.onForcePressStart != null ? _forcePressStarted : null + widget.onForcePressStart != null ? _forcePressStarted : null ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; }, ); From 36fe118b7cba2166dc034a8608c3565981b73da0 Mon Sep 17 00:00:00 2001 From: Ellet Date: Tue, 8 Apr 2025 18:09:52 +0300 Subject: [PATCH 08/12] docs: improve CHANGELOG --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf975ca8d..42f943fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Allow selection in read only mode -- Display magnifier when dragging on iOS/Android -- Fixes [#2518](https://github.com/singerdmx/flutter-quill/pull/2518) +### 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 From a66bd3448bce723f6eec5be25db9ecaad39239f2 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 8 Apr 2025 12:50:37 -0700 Subject: [PATCH 09/12] added dragOffsetNotifier explanation --- lib/src/editor/raw_editor/raw_editor.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index f88b29453..b575f0b9c 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -25,6 +25,25 @@ class QuillRawEditor extends StatefulWidget { final QuillController controller; final QuillRawEditorConfig config; + + /// 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. final ValueNotifier? dragOffsetNotifier; @override From e3f3763dc9e75822f203a2acd64a673b74b05f83 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 8 Apr 2025 14:25:14 -0700 Subject: [PATCH 10/12] created [QuillMagnifer] and added a [QuillEditorConfig] parameter quillMangifierBuilder, also added a defaultQuillMagnifierBuilder function, when not specified the magnifier will not be used. --- lib/flutter_quill.dart | 1 + lib/src/editor/config/editor_config.dart | 10 ++++ lib/src/editor/editor.dart | 2 + .../raw_editor/config/raw_editor_config.dart | 5 ++ lib/src/editor/widgets/delegate.dart | 3 ++ lib/src/editor/widgets/text/magnifier.dart | 43 ++++++++++++++++ .../editor/widgets/text/text_selection.dart | 49 +++++-------------- 7 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 lib/src/editor/widgets/text/magnifier.dart 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 aa41bc3c7..bf8707db2 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -15,6 +15,7 @@ import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; import '../widgets/text/utils/text_block_utils.dart'; +import '../widgets/text/magnifier.dart'; import 'search_config.dart'; // IMPORTANT For project authors: The QuillEditorConfig.copyWith() @@ -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, @@ -365,6 +367,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 23e2d22f8..44003fda4 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -309,6 +309,7 @@ class QuillEditorState extends State scrollPhysics: config.scrollPhysics, embedBuilder: _getEmbedBuilder, textSpanBuilder: config.textSpanBuilder, + quillMagnifierBuilder: config.quillMagnifierBuilder, linkActionPickerDelegate: config.linkActionPickerDelegate, customStyleBuilder: config.customStyleBuilder, customRecognizerBuilder: config.customRecognizerBuilder, @@ -335,6 +336,7 @@ class QuillEditorState extends State 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/widgets/delegate.dart b/lib/src/editor/widgets/delegate.dart index 1e0de1fad..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); @@ -362,6 +363,7 @@ class EditorTextSelectionGestureDetectorBuilder { Key? key, bool detectWordBoundary = true, ValueNotifier? dragOffsetNotifier, + QuillMagnifierBuilder? quillMagnifierBuilder, }) { return EditorTextSelectionGestureDetector( key: key, @@ -381,6 +383,7 @@ class EditorTextSelectionGestureDetectorBuilder { 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 65322d0f3..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; @@ -678,6 +679,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { this.behavior, this.detectWordBoundary = true, this.dragOffsetNotifier, + this.quillMagnifierBuilder, super.key, }); @@ -757,6 +759,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { final ValueNotifier? dragOffsetNotifier; + final QuillMagnifierBuilder? quillMagnifierBuilder; + @override State createState() => _EditorTextSelectionGestureDetectorState(); @@ -1055,42 +1059,15 @@ class _EditorTextSelectionGestureDetectorState gestures: gestures, excludeFromSemantics: true, behavior: widget.behavior, - child: Stack( - children: [ - widget.child, - if (_magnifierPosition != null) - Builder(builder: (context) { - final position = _magnifierPosition!.translate(-60, -80); - return Positioned( - top: position.dy, - left: position.dx, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).scaffoldBackgroundColor, - ), - 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, - ), - ), - ); - }), - ], - ), + child: (widget.quillMagnifierBuilder == null) + ? widget.child + : Stack( + children: [ + widget.child, + if (_magnifierPosition != null) + widget.quillMagnifierBuilder!(_magnifierPosition!) + ], + ), ); } } From d247d878ae1c165302aeeb0ac3d6de87df61d0eb Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 8 Apr 2025 14:27:36 -0700 Subject: [PATCH 11/12] reorder imports --- lib/src/editor/config/editor_config.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index bf8707db2..9cfeb5742 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -14,8 +14,8 @@ import '../raw_editor/raw_editor.dart'; import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; import '../widgets/link.dart'; -import '../widgets/text/utils/text_block_utils.dart'; import '../widgets/text/magnifier.dart'; +import '../widgets/text/utils/text_block_utils.dart'; import 'search_config.dart'; // IMPORTANT For project authors: The QuillEditorConfig.copyWith() From 542ab43e61a6774a3993b57de686577ad2e8fa81 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 8 Apr 2025 14:38:39 -0700 Subject: [PATCH 12/12] update dragOffsetNotifier documentation --- lib/src/editor/editor.dart | 2 +- lib/src/editor/raw_editor/raw_editor.dart | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 44003fda4..117819392 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -196,7 +196,7 @@ class QuillEditorState extends State QuillEditorConfig get configurations => widget.config; QuillEditorConfig get config => widget.config; - // The offset of the drag gesture - where to display the magnifier + /// {@macro drag_offset_notifier} final dragOffsetNotifier = isMobileApp ? ValueNotifier(null) : null; @override diff --git a/lib/src/editor/raw_editor/raw_editor.dart b/lib/src/editor/raw_editor/raw_editor.dart index b575f0b9c..edfaecf35 100644 --- a/lib/src/editor/raw_editor/raw_editor.dart +++ b/lib/src/editor/raw_editor/raw_editor.dart @@ -26,6 +26,7 @@ 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] @@ -44,6 +45,7 @@ class QuillRawEditor extends StatefulWidget { /// 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? dragOffsetNotifier; @override