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!)
+              ],
+            ),
     );
   }
 }