Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Fix caret not auto scroll to visible when page has multiple editors [#2570](https://github.com/singerdmx/flutter-quill/pull/2570).
**BREAKING**: QuillEditorConfig is now accept `EdgeInsets padding` instead of `EdgeInsetsGeometry`.

## [11.4.1] - 2025-05-15

### Added
Expand Down
4 changes: 2 additions & 2 deletions lib/src/editor/config/editor_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ class QuillEditorConfig {

/// Additional space around the content of this editor.
/// by default will be [EdgeInsets.zero]
final EdgeInsetsGeometry padding;
final EdgeInsets padding;

/// Whether this editor should focus itself if nothing else is already
/// focused.
Expand Down Expand Up @@ -484,7 +484,7 @@ class QuillEditorConfig {
bool? scrollable,
double? scrollBottomInset,
bool? enableAlwaysIndentOnTab,
EdgeInsetsGeometry? padding,
EdgeInsets? padding,
bool? autoFocus,
bool? onTapOutsideEnabled,
Function(PointerDownEvent event, FocusNode focusNode)? onTapOutside,
Expand Down
69 changes: 4 additions & 65 deletions lib/src/editor/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1222,71 +1222,6 @@ class RenderEditor extends RenderEditableContainerBox
);
}

/// Returns the y-offset of the editor at which [selection] is visible.
///
/// The offset is the distance from the top of the editor and is the minimum
/// from the current scroll position until [selection] becomes visible.
/// Returns null if [selection] is already visible.
///
/// Finds the closest scroll offset that fully reveals the editing cursor.
///
/// The `scrollOffset` parameter represents current scroll offset in the
/// parent viewport.
///
/// The `offsetInViewport` parameter represents the editor's vertical offset
/// in the parent viewport. This value should normally be 0.0 if this editor
/// is the only child of the viewport or if it's the topmost child. Otherwise
/// it should be a positive value equal to total height of all siblings of
/// this editor from above it.
///
/// Returns `null` if the cursor is currently visible.
double? getOffsetToRevealCursor(
double viewportHeight, double scrollOffset, double offsetInViewport) {
// Endpoints coordinates represents lower left or lower right corner of
// the selection. If we want to scroll up to reveal the caret we need to
// adjust the dy value by the height of the line. We also add a small margin
// so that the caret is not too close to the edge of the viewport.
final endpoints = getEndpointsForSelection(selection);

// when we drag the right handle, we should get the last point
TextSelectionPoint endpoint;
if (selection.isCollapsed) {
endpoint = endpoints.first;
} else {
if (selection is DragTextSelection) {
endpoint = (selection as DragTextSelection).first
? endpoints.first
: endpoints.last;
} else {
endpoint = endpoints.first;
}
}

// Collapsed selection => caret
final child = childAtPosition(selection.extent);
const kMargin = 8.0;

final caretTop = endpoint.point.dy -
child.preferredLineHeight(TextPosition(
offset: selection.extentOffset - child.container.documentOffset)) -
kMargin +
offsetInViewport +
scrollBottomInset;
final caretBottom =
endpoint.point.dy + kMargin + offsetInViewport + scrollBottomInset;
double? dy;
if (caretTop < scrollOffset) {
dy = caretTop;
} else if (caretBottom > scrollOffset + viewportHeight) {
dy = caretBottom - viewportHeight;
}
if (dy == null) {
return null;
}
// Clamping to 0.0 so that the content does not jump unnecessarily.
return math.max(dy, 0);
}

@override
Rect getLocalRectForCaret(TextPosition position) {
final targetChild = childAtPosition(position);
Expand All @@ -1298,6 +1233,10 @@ class RenderEditor extends RenderEditableContainerBox
return childLocalRect.shift(Offset(0, boxParentData.offset.dy));
}

TextPosition get caretTextPosition => _floatingCursorRect == null
? selection.extent
: _floatingCursorTextPosition;

// Start floating cursor

FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter(
Expand Down
2 changes: 1 addition & 1 deletion lib/src/editor/raw_editor/config/raw_editor_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class QuillRawEditorConfig {
final KeyEventResult? Function(KeyEvent event, Node? node)? onKeyPressed;

/// Additional space around the editor contents.
final EdgeInsetsGeometry padding;
final EdgeInsets padding;

/// Enables always indenting when the TAB key is pressed.
///
Expand Down
207 changes: 166 additions & 41 deletions lib/src/editor/raw_editor/raw_editor_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import 'dart:ui' as ui hide TextStyle;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderAbstractViewport;
import 'package:flutter/rendering.dart' show RevealedOffset;
import 'package:flutter/scheduler.dart' show SchedulerBinding;
import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility_temp_fork/flutter_keyboard_visibility_temp_fork.dart'
Expand Down Expand Up @@ -94,6 +94,57 @@ class QuillRawEditorState extends EditorState
bool get dirty => _dirty;
bool _dirty = false;

// Completely copied from flutter with some changes to fit flutter_quill:
// https://github.com/flutter/flutter/blob/3.29.0/packages/flutter/lib/src/widgets/editable_text.dart#L3741
// Finds the closest scroll offset to the current scroll offset that fully
// reveals the given caret rect. If the given rect's main axis extent is too
// large to be fully revealed in `renderEditable`, it will be centered along
// the main axis.
//
// If this is a multiline EditableText (which means the Editable can only
// scroll vertically), the given rect's height will first be extended to match
// `renderEditable.preferredLineHeight`, before the target scroll offset is
// calculated.
RevealedOffset _getOffsetToRevealCaret(Rect rect) {
if (!_scrollController.position.allowImplicitScrolling) {
return RevealedOffset(offset: _scrollController.offset, rect: rect);
}

final editableSize = renderEditor.size;
final double additionalOffset;
final Offset unitOffset;

// The caret is vertically centered within the line. Expand the caret's
// height so that it spans the line because we're going to ensure that the
// entire expanded caret is scrolled into view.
final expandedRect = Rect.fromCenter(
center: rect.center,
width: rect.width,
height: math.max(
rect.height,
renderEditor.preferredLineHeight(renderEditor.caretTextPosition),
),
);

additionalOffset = expandedRect.height >= editableSize.height
? editableSize.height / 2 - expandedRect.center.dy
: ui.clampDouble(
0, expandedRect.bottom - editableSize.height, expandedRect.top);
unitOffset = const Offset(0, 1);

// No overscrolling when encountering tall fonts/scripts that extend past
// the ascent.
final targetOffset = ui.clampDouble(
additionalOffset + _scrollController.offset,
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent,
);

final offsetDelta = _scrollController.offset - targetOffset;
return RevealedOffset(
rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
}

@override
void insertContent(KeyboardInsertedContent content) {
assert(widget.config.contentInsertionConfiguration?.allowedMimeTypes
Expand Down Expand Up @@ -537,7 +588,6 @@ class QuillRawEditorState extends EditorState
final requestKeyboardFocusOnCheckListChanged =
widget.config.requestKeyboardFocusOnCheckListChanged;
if (!(widget.config.checkBoxReadOnly ?? widget.config.readOnly)) {
_disableScrollControllerAnimateOnce = true;
final currentSelection = controller.selection.copyWith();
final attribute = value ? Attribute.checked : Attribute.unchecked;

Expand Down Expand Up @@ -1026,12 +1076,35 @@ class QuillRawEditorState extends EditorState
.stopCurrentVerticalRunIfSelectionChanges();
}

late double _lastBottomViewInset;

@override
void didChangeMetrics() {
if (!mounted) {
return;
}

// https://github.com/flutter/flutter/blob/3.29.0/packages/flutter/lib/src/widgets/editable_text.dart#L4311
final view = View.of(context);
if (_lastBottomViewInset != view.viewInsets.bottom) {
SchedulerBinding.instance.addPostFrameCallback((_) {
_selectionOverlay?.updateForScroll();
}, debugLabel: 'EditableText.updateForScroll');
if (_lastBottomViewInset < view.viewInsets.bottom) {
// Because the metrics change signal from engine will come here every frame
// (on both iOS and Android). So we don't need to show caret with animation.
_scheduleShowCaretOnScreen(withAnimation: false);
}
}
_lastBottomViewInset = view.viewInsets.bottom;
}

void _onChangeTextEditingValue([bool ignoreCaret = false]) {
updateRemoteValueIfNeeded();
if (ignoreCaret) {
return;
}
_showCaretOnScreen();
_scheduleShowCaretOnScreen(withAnimation: true);
_cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection);
if (hasConnection) {
// To keep the cursor from blinking while typing, we want to restart the
Expand Down Expand Up @@ -1101,7 +1174,8 @@ class QuillRawEditorState extends EditorState
_updateOrDisposeSelectionOverlayIfNeeded();
if (_hasFocus) {
WidgetsBinding.instance.addObserver(this);
_showCaretOnScreen();
_lastBottomViewInset = View.of(context).viewInsets.bottom;
_scheduleShowCaretOnScreen(withAnimation: true);
} else {
WidgetsBinding.instance.removeObserver(this);
}
Expand All @@ -1120,53 +1194,104 @@ class QuillRawEditorState extends EditorState
return widget.config.linkActionPickerDelegate(context, link, linkNode);
}

bool _showCaretOnScreenScheduled = false;
// Animation configuration for scrolling the caret back on screen.
static const Duration _caretAnimationDuration = Duration(milliseconds: 100);
static const Curve _caretAnimationCurve = Curves.fastOutSlowIn;

// This is a workaround for checkbox tapping issue
// https://github.com/singerdmx/flutter-quill/issues/619
// We cannot treat {"list": "checked"} and {"list": "unchecked"} as
// block of the same style
// This causes controller.selection to go to offset 0
bool _disableScrollControllerAnimateOnce = false;
bool _showCaretOnScreenScheduled = false;

void _showCaretOnScreen() {
if (!widget.config.showCursor || _showCaretOnScreenScheduled) {
// Completely copied from flutter with some changes to fit flutter_quill:
// https://github.com/flutter/flutter/blob/3.29.0/packages/flutter/lib/src/widgets/editable_text.dart#L4228
void _scheduleShowCaretOnScreen({required bool withAnimation}) {
if (_showCaretOnScreenScheduled) {
return;
}

_showCaretOnScreenScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (widget.config.scrollable || _scrollController.hasClients) {
_showCaretOnScreenScheduled = false;
_showCaretOnScreenScheduled = false;
// Since we are in a post frame callback, check currentContext in case
// RenderEditable has been disposed (in which case it will be null).
final renderEditor =
_editorKey.currentContext?.findRenderObject() as RenderEditor?;
if (renderEditor == null ||
!renderEditor.selection.isValid ||
!_scrollController.hasClients) {
return;
}

if (!mounted) {
return;
}
final lineHeight =
renderEditor.preferredLineHeight(renderEditor.caretTextPosition);

final viewport = RenderAbstractViewport.of(renderEditor);
final editorOffset =
renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController.offset + editorOffset.dy;
// Enlarge the target rect by scrollPadding to ensure that caret is not
// positioned directly at the edge after scrolling.
var bottomSpacing = widget.config.padding.bottom;
if (_selectionOverlay?.selectionCtrls != null) {
final handleHeight =
_selectionOverlay!.selectionCtrls.getHandleSize(lineHeight).height;

final offset = renderEditor.getOffsetToRevealCursor(
_scrollController.position.viewportDimension,
_scrollController.offset,
offsetInViewport,
final double interactiveHandleHeight = math.max(
handleHeight,
kMinInteractiveDimension,
);

if (offset != null) {
if (_disableScrollControllerAnimateOnce) {
_disableScrollControllerAnimateOnce = false;
return;
}
_scrollController.animateTo(
math.min(offset, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn,
);
}
final anchor = _selectionOverlay!.selectionCtrls.getHandleAnchor(
TextSelectionHandleType.collapsed,
lineHeight,
);

final handleCenter = handleHeight / 2 - anchor.dy;
bottomSpacing = math.max(
handleCenter + interactiveHandleHeight / 2,
bottomSpacing,
);
}
});

final caretPadding =
widget.config.padding.copyWith(bottom: bottomSpacing);

final caretRect =
renderEditor.getLocalRectForCaret(renderEditor.caretTextPosition);
final targetOffset = _getOffsetToRevealCaret(caretRect);

Rect? rectToReveal;
final selection = textEditingValue.selection;
if (selection.isCollapsed) {
rectToReveal = targetOffset.rect;
} else {
// TODO: I'm not sure how to get getBoxesForSelection in flutter_quill or do we even has it?
// Currently just return targetOffset.rect.
//
// final List<TextBox> selectionBoxes =
// renderEditor.getBoxesForSelection(selection);
// // selectionBoxes may be empty if, for example, the selection does not
// // encompass a full character, like if it only contained part of an
// // extended grapheme cluster.
// if (selectionBoxes.isEmpty) {
// rectToReveal = targetOffset.rect;
// } else {
// rectToReveal = selection.baseOffset < selection.extentOffset
// ? selectionBoxes.last.toRect()
// : selectionBoxes.first.toRect();
// }
rectToReveal = targetOffset.rect;
}

if (withAnimation) {
_scrollController.animateTo(
targetOffset.offset,
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
renderEditor.showOnScreen(
rect: caretPadding.inflateRect(rectToReveal),
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
} else {
_scrollController.jumpTo(targetOffset.offset);
renderEditor.showOnScreen(rect: caretPadding.inflateRect(rectToReveal));
}
}, debugLabel: 'EditableText.showCaret');
}

/// The renderer for this widget's editor descendant.
Expand Down Expand Up @@ -1196,10 +1321,10 @@ class QuillRawEditorState extends EditorState
/// delay 500 milliseconds for waiting keyboard show up
Future.delayed(
const Duration(milliseconds: 500),
_showCaretOnScreen,
() => _scheduleShowCaretOnScreen(withAnimation: true),
);
} else {
_showCaretOnScreen();
_scheduleShowCaretOnScreen(withAnimation: true);
}
} else {
widget.config.focusNode.requestFocus();
Expand Down