From 679c3db9a765e4a466a73c68d33cfcf1fc3979fe Mon Sep 17 00:00:00 2001 From: RekanDev Date: Fri, 21 Aug 2026 10:54:45 +0300 Subject: [PATCH 1/2] fix: hide/restore selection toolbar on scroll like TextField MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the scroll viewport for selection-handle visibility instead of the full document size, clamp context-menu anchors into the visible region, and hide the Cut/Copy/Paste menu while scrolling — restoring it only when the selection is on-screen again (including after scrolling away and back). Co-authored-by: Cursor --- CHANGELOG.md | 2 + lib/src/editor/editor.dart | 83 ++++-- .../editor/raw_editor/raw_editor_state.dart | 238 +++++++++++++++--- 3 files changed, 272 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91fe0db55..d30695093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue where bullet points became visually detached from the text body when toggling text direction formatting (RTL) by locking the list leading block to the editor's base text direction. - Fixed typed text being inserted at the previous caret position on Android after moving the caret with a tap/mouse by keeping the platform IME's editing state in sync with the selection even when the keyboard is hidden. +- Selection handles and Cut/Copy/Paste toolbar now track the scroll viewport like a multiline `TextField`: handles fade when scrolled out of view, and the context menu hides while scrolling (restored if the selection is still visible). +- Context menu anchors are clamped into the visible editor region when the selection extends past the viewport. ### Removed diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 4bd9d7927..7fa235af3 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -726,13 +726,34 @@ class RenderEditor extends RenderEditableContainerBox ValueListenable get selectionEndInViewport => _selectionEndInViewport; final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + /// Visible area of this editor in local coordinates. + /// + /// When the editor is inside a [Scrollable] (e.g. [SingleChildScrollView]), + /// [size] is the full document — not the on-screen window. Using the ancestor + /// viewport matches [RenderEditable] / multiline [TextField] behavior so + /// selection handles fade when scrolled out of view. + Rect _localVisibleRegion() { + final abstractViewport = RenderAbstractViewport.maybeOf(this); + if (abstractViewport is! RenderBox) { + return Offset.zero & size; + } + final viewport = abstractViewport as RenderBox; + if (!viewport.hasSize || !hasSize) { + return Offset.zero & size; + } + final topLeft = globalToLocal(viewport.localToGlobal(Offset.zero)); + final rect = topLeft & viewport.size; + if (!rect.isEmpty && + rect.width.isFinite && + rect.height.isFinite && + rect.left <= rect.right && + rect.top <= rect.bottom) { + return rect; + } + return Offset.zero & size; + } + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { - final visibleRegion = Offset.zero & size; - final startPosition = TextPosition( - offset: selection.start, - affinity: selection.affinity, - ); - final startOffset = _getOffsetForCaret(startPosition); // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 // Check if the selection is visible with an approximation because a // difference between rounded and unrounded values causes the caret to be @@ -741,18 +762,37 @@ class RenderEditor extends RenderEditableContainerBox // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and // this can be changed to be a strict check instead of an approximation. const visibleRegionSlop = 0.5; - _selectionStartInViewport.value = visibleRegion - .inflate(visibleRegionSlop) - .contains(startOffset + effectiveOffset); - final endPosition = TextPosition( - offset: selection.end, - affinity: selection.affinity, - ); + final abstractViewport = RenderAbstractViewport.maybeOf(this); + final Rect visibleRegion; + final Offset pointOffset; + if (abstractViewport is RenderBox) { + final viewport = abstractViewport as RenderBox; + if (viewport.hasSize) { + // Viewport-relative check: caret offsets are already in local document + // space; do not apply [effectiveOffset] again. + visibleRegion = _localVisibleRegion().inflate(visibleRegionSlop); + pointOffset = Offset.zero; + } else { + visibleRegion = (Offset.zero & size).inflate(visibleRegionSlop); + pointOffset = effectiveOffset; + } + } else { + visibleRegion = (Offset.zero & size).inflate(visibleRegionSlop); + pointOffset = effectiveOffset; + } + + final startPosition = + TextPosition(offset: selection.start, affinity: selection.affinity); + final startOffset = _getOffsetForCaret(startPosition); + _selectionStartInViewport.value = + visibleRegion.contains(startOffset + pointOffset); + + final endPosition = + TextPosition(offset: selection.end, affinity: selection.affinity); final endOffset = _getOffsetForCaret(endPosition); - _selectionEndInViewport.value = visibleRegion - .inflate(visibleRegionSlop) - .contains(endOffset + effectiveOffset); + _selectionEndInViewport.value = + visibleRegion.contains(endOffset + pointOffset); } // returns offset relative to this at which the caret will be painted @@ -1219,10 +1259,13 @@ class RenderEditor extends RenderEditableContainerBox PaintingContext context, List endpoints, ) { + // Clamp into the on-screen region (viewport), not the full document size, + // matching RenderEditable when the editable itself is the scroll viewport. + final visible = _localVisibleRegion(); var startPoint = endpoints[0].point; startPoint = Offset( - startPoint.dx.clamp(0.0, size.width), - startPoint.dy.clamp(0.0, size.height), + startPoint.dx.clamp(visible.left, visible.right), + startPoint.dy.clamp(visible.top, visible.bottom), ); context.pushLayer( LeaderLayer(link: _startHandleLayerLink, offset: startPoint), @@ -1232,8 +1275,8 @@ class RenderEditor extends RenderEditableContainerBox if (endpoints.length == 2) { var endPoint = endpoints[1].point; endPoint = Offset( - endPoint.dx.clamp(0.0, size.width), - endPoint.dy.clamp(0.0, size.height), + endPoint.dx.clamp(visible.left, visible.right), + endPoint.dy.clamp(visible.top, visible.bottom), ); context.pushLayer( LeaderLayer(link: _endHandleLayerLink, offset: endPoint), diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index 2c2299f14..7b36863ff 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -88,6 +88,12 @@ class QuillRawEditorState extends EditorState final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); + /// Snapshot used to restore the selection toolbar after scrolling, matching + /// [EditableTextState]'s hide-on-scroll / show-when-still-visible behavior. + ({TextEditingValue value, Rect selectionBounds})? + _dataWhenToolbarShowScheduled; + bool _showToolbarOnScreenScheduled = false; + TextDirection get _textDirection => Directionality.of(context); @override @@ -248,17 +254,66 @@ class QuillRawEditorState extends EditorState /// Returns the anchor points for the default context menu. /// - /// Copied from [EditableTextState]. + /// Copied from [EditableTextState], with viewport clamping so the menu stays + /// reachable when the selection extends past the visible editor region. TextSelectionToolbarAnchors get contextMenuAnchors { final glyphHeights = _getGlyphHeights(); final selection = textEditingValue.selection; final points = renderEditor.getEndpointsForSelection(selection); - return TextSelectionToolbarAnchors.fromSelection( + final anchors = TextSelectionToolbarAnchors.fromSelection( renderBox: renderEditor, startGlyphHeight: glyphHeights.startGlyphHeight, endGlyphHeight: glyphHeights.endGlyphHeight, selectionEndpoints: points, ); + return _clampAnchorsToVisibleViewport(anchors); + } + + static const double _kSelectionContextMenuReserve = + kMinInteractiveDimension + 16; + + TextSelectionToolbarAnchors _clampAnchorsToVisibleViewport( + TextSelectionToolbarAnchors anchors, + ) { + final editor = renderEditor; + if (!editor.hasSize) { + return anchors; + } + final viewportObject = RenderAbstractViewport.maybeOf(editor); + if (viewportObject is! RenderBox) { + return anchors; + } + final viewport = viewportObject as RenderBox; + if (!viewport.hasSize) { + return anchors; + } + + final editorTop = editor.localToGlobal(Offset.zero).dy; + final editorBottom = + editor.localToGlobal(Offset(0, editor.size.height)).dy; + final viewportTop = viewport.localToGlobal(Offset.zero).dy; + final viewportBottom = + viewport.localToGlobal(Offset(0, viewport.size.height)).dy; + final visibleTop = math.max(editorTop, viewportTop); + final visibleBottom = math.min(editorBottom, viewportBottom); + + if (visibleBottom - visibleTop <= _kSelectionContextMenuReserve) { + return anchors; + } + + Offset clampY(Offset point, double low, double high) => + Offset(point.dx, point.dy.clamp(low, high)); + + return TextSelectionToolbarAnchors( + primaryAnchor: clampY(anchors.primaryAnchor, visibleTop, visibleBottom), + secondaryAnchor: anchors.secondaryAnchor == null + ? null + : clampY( + anchors.secondaryAnchor!, + visibleTop, + visibleBottom - _kSelectionContextMenuReserve, + ), + ); } /// Gets the line heights at the start and end of the selection for the given @@ -405,35 +460,42 @@ class QuillRawEditorState extends EditorState textStyle: _styles!.paragraph!.style, padding: baselinePadding, child: _scribbleFocusable( - SingleChildScrollView( - controller: _scrollController, - physics: widget.config.scrollPhysics, - child: CompositedTransformTarget( - link: _toolbarLayerLink, - child: MouseRegion( - cursor: widget.config.readOnly - ? widget.config.readOnlyMouseCursor - : SystemMouseCursors.text, - child: QuillRawEditorMultiChildRenderObject( - key: _editorKey, - offset: _scrollController.hasClients - ? _scrollController.position - : null, - document: doc, - selection: controller.selection, - hasFocus: _hasFocus, - scrollable: widget.config.scrollable, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - onSelectionCompleted: _handleSelectionCompleted, - scrollBottomInset: widget.config.scrollBottomInset, - padding: widget.config.padding, - maxContentWidth: widget.config.maxContentWidth, - cursorController: _cursorCont, - floatingCursorDisabled: widget.config.floatingCursorDisabled, - children: _buildChildren(doc, context), + NotificationListener( + onNotification: (notification) { + _handleContextMenuOnScroll(notification); + return false; + }, + child: SingleChildScrollView( + controller: _scrollController, + physics: widget.config.scrollPhysics, + child: CompositedTransformTarget( + link: _toolbarLayerLink, + child: MouseRegion( + cursor: widget.config.readOnly + ? widget.config.readOnlyMouseCursor + : SystemMouseCursors.text, + child: QuillRawEditorMultiChildRenderObject( + key: _editorKey, + offset: _scrollController.hasClients + ? _scrollController.position + : null, + document: doc, + selection: controller.selection, + hasFocus: _hasFocus, + scrollable: widget.config.scrollable, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + onSelectionCompleted: _handleSelectionCompleted, + scrollBottomInset: widget.config.scrollBottomInset, + padding: widget.config.padding, + maxContentWidth: widget.config.maxContentWidth, + cursorController: _cursorCont, + floatingCursorDisabled: + widget.config.floatingCursorDisabled, + children: _buildChildren(doc, context), + ), ), ), ), @@ -1010,6 +1072,116 @@ class QuillRawEditorState extends EditorState _selectionOverlay?.updateForScroll(); } + /// Whether this platform hides the selection toolbar while scrolling, then + /// may restore it afterward (same platforms as [EditableText]). + bool get _platformSupportsFadeOnScroll => switch (defaultTargetPlatform) { + TargetPlatform.android || TargetPlatform.iOS => true, + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => + false, + }; + + /// Mirrors [EditableTextState._handleContextMenuOnScroll]: hide Cut/Copy/Paste + /// while scrolling; restore when scrolling ends if the selection is still + /// visible. + void _handleContextMenuOnScroll(ScrollNotification notification) { + if (kIsWeb) { + return; + } + if (!_platformSupportsFadeOnScroll) { + _selectionOverlay?.updateForScroll(); + return; + } + + if (notification is ScrollStartNotification) { + if (_dataWhenToolbarShowScheduled != null) { + return; + } + final toolbarIsVisible = _selectionOverlay?.toolbar != null; + if (!toolbarIsVisible) { + return; + } + + final selection = textEditingValue.selection; + final baseRect = renderEditor.getLocalRectForCaret(selection.base); + final extentRect = renderEditor.getLocalRectForCaret(selection.extent); + final selectionBounds = selection.isCollapsed + ? extentRect + : baseRect.expandToInclude(extentRect); + + _dataWhenToolbarShowScheduled = ( + value: textEditingValue, + selectionBounds: selectionBounds, + ); + hideToolbar(false); + } else if (notification is ScrollEndNotification) { + if (_dataWhenToolbarShowScheduled == null) { + return; + } + if (_dataWhenToolbarShowScheduled!.value != textEditingValue) { + _dataWhenToolbarShowScheduled = null; + return; + } + if (_showToolbarOnScreenScheduled) { + return; + } + _showToolbarOnScreenScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _showToolbarOnScreenScheduled = false; + if (!mounted || _dataWhenToolbarShowScheduled == null) { + return; + } + if (_dataWhenToolbarShowScheduled!.value != textEditingValue) { + _dataWhenToolbarShowScheduled = null; + return; + } + + // Keep [_dataWhenToolbarShowScheduled] until the selection is on-screen + // again (e.g. user scrolled away then back). Clearing it early is why + // scrolling up could hide the menu permanently while scrolling down + // still restored it. + final selectionVisible = renderEditor.selectionStartInViewport.value || + renderEditor.selectionEndInViewport.value; + if (!selectionVisible) { + return; + } + + final selection = textEditingValue.selection; + final baseRect = renderEditor.getLocalRectForCaret(selection.base); + final extentRect = renderEditor.getLocalRectForCaret(selection.extent); + final currentBounds = selection.isCollapsed + ? extentRect + : baseRect.expandToInclude(extentRect); + + if (_selectionBoundsInViewport(currentBounds)) { + showToolbar(); + _dataWhenToolbarShowScheduled = null; + } + }); + } + } + + bool _selectionBoundsInViewport(Rect selectionBounds) { + var closestViewport = RenderAbstractViewport.maybeOf(renderEditor); + while (closestViewport != null) { + final selectionBoundsLocalToViewport = MatrixUtils.transformRect( + renderEditor.getTransformTo(closestViewport), + selectionBounds, + ); + if (selectionBoundsLocalToViewport.hasNaN || + closestViewport.paintBounds.hasNaN || + !closestViewport.paintBounds + .overlaps(selectionBoundsLocalToViewport)) { + return false; + } + closestViewport = + RenderAbstractViewport.maybeOf(closestViewport.parent); + } + return true; + } + void _onComposingRangeChanged() { if (!mounted) { return; @@ -1029,6 +1201,10 @@ class QuillRawEditorState extends EditorState } void _didChangeTextEditingValue([bool ignoreFocus = false]) { + if (_dataWhenToolbarShowScheduled != null && + _dataWhenToolbarShowScheduled!.value != textEditingValue) { + _dataWhenToolbarShowScheduled = null; + } if (kIsWeb) { _onChangeTextEditingValue(ignoreFocus); if (!ignoreFocus) { From 04b10785623517456414322fce84c4c06de6e73f Mon Sep 17 00:00:00 2001 From: RekanDev Date: Fri, 21 Aug 2026 10:59:40 +0300 Subject: [PATCH 2/2] fix: show context menu again after selection handle drag TextField restores Cut/Copy/Paste when a handle drag ends; Quill only cleared the magnifier offset. Recreate or rebuild the toolbar when handles settle so adjusting anchors always brings the menu back. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../editor/raw_editor/raw_editor_state.dart | 3 +++ .../editor/widgets/text/text_selection.dart | 25 +++++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d30695093..3507bd90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed typed text being inserted at the previous caret position on Android after moving the caret with a tap/mouse by keeping the platform IME's editing state in sync with the selection even when the keyboard is hidden. - Selection handles and Cut/Copy/Paste toolbar now track the scroll viewport like a multiline `TextField`: handles fade when scrolled out of view, and the context menu hides while scrolling (restored if the selection is still visible). - Context menu anchors are clamped into the visible editor region when the selection extends past the viewport. +- Context menu is shown again after selection-handle drag ends (or a handle tap), matching `TextField` behavior. ### Removed diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index 7b36863ff..83a3d6b06 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -1439,6 +1439,9 @@ class QuillRawEditorState extends EditorState return false; } + // A successful show cancels any pending post-scroll restore. + _dataWhenToolbarShowScheduled = null; + _selectionOverlay!.update(textEditingValue); _selectionOverlay!.showToolbar(); return true; diff --git a/lib/src/editor/widgets/text/text_selection.dart b/lib/src/editor/widgets/text/text_selection.dart index 93f310de7..78e63a10e 100644 --- a/lib/src/editor/widgets/text/text_selection.dart +++ b/lib/src/editor/widgets/text/text_selection.dart @@ -254,9 +254,23 @@ class EditorTextSelectionOverlay { // after dragging and magnifier is removed, restore the context menu void _dragOffsetListener() { - if (dragOffsetNotifier?.value == null) { - toolbar?.markNeedsBuild(); + if (dragOffsetNotifier?.value != null) { + return; + } + _showToolbarAfterHandleInteraction(); + } + + /// Shows or rebuilds the context menu after a selection-handle drag ends, + /// matching [TextSelectionOverlay] / multiline [TextField] behavior. + void _showToolbarAfterHandleInteraction() { + if (_selection.isCollapsed || contextMenuBuilder == null) { + return; + } + if (toolbar != null) { + toolbar!.markNeedsBuild(); + return; } + showToolbar(); } Widget _buildHandle( @@ -274,6 +288,7 @@ class EditorTextSelectionOverlay { _handleSelectionHandleChanged(newSelection, position); }, onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragEnd: _showToolbarAfterHandleInteraction, startHandleLayerLink: startHandleLayerLink, endHandleLayerLink: endHandleLayerLink, renderObject: renderObject, @@ -411,6 +426,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { required this.onSelectionHandleChanged, required this.onSelectionHandleTapped, required this.selectionControls, + this.onSelectionHandleDragEnd, this.dragStartBehavior = DragStartBehavior.start, this.dragOffsetNotifier, }); @@ -422,6 +438,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { final RenderEditor renderObject; final ValueChanged onSelectionHandleChanged; final VoidCallback? onSelectionHandleTapped; + final VoidCallback? onSelectionHandleDragEnd; final TextSelectionControls selectionControls; final DragStartBehavior dragStartBehavior; final ValueNotifier? dragOffsetNotifier; @@ -499,6 +516,9 @@ class _TextSelectionHandleOverlayState void _handleDragEnd(DragEndDetails details) { // when the drag is complete, we need to clear the drag offset widget.dragOffsetNotifier?.value = null; + // TextField shows Cut/Copy/Paste again when the handle settles; do the same + // even if the toolbar overlay was removed earlier (e.g. while scrolling). + widget.onSelectionHandleDragEnd?.call(); } void _handleDragUpdate(DragUpdateDetails details) { @@ -547,6 +567,7 @@ class _TextSelectionHandleOverlayState void _handleTap() { widget.onSelectionHandleTapped?.call(); + widget.onSelectionHandleDragEnd?.call(); } @override