dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1776 lines
59 KiB
1776 lines
59 KiB
import 'dart:math' as math; |
|
|
|
import 'package:flutter/cupertino.dart' |
|
show CupertinoTheme, cupertinoTextSelectionControls; |
|
import 'package:flutter/foundation.dart' |
|
show ValueListenable, defaultTargetPlatform; |
|
import 'package:flutter/gestures.dart' show PointerDeviceKind; |
|
import 'package:flutter/material.dart'; |
|
import 'package:flutter/rendering.dart'; |
|
import 'package:flutter/services.dart'; |
|
|
|
import '../common/utils/platform.dart'; |
|
import '../document/document.dart'; |
|
import '../document/nodes/container.dart' as container_node; |
|
import '../document/nodes/leaf.dart'; |
|
import '../l10n/widgets/localizations.dart'; |
|
import 'config/editor_configurations.dart'; |
|
import 'editor_builder.dart'; |
|
import 'embed/embed_editor_builder.dart'; |
|
import 'provider.dart'; |
|
import 'raw_editor/config/raw_editor_configurations.dart'; |
|
import 'raw_editor/raw_editor.dart'; |
|
import 'widgets/box.dart'; |
|
import 'widgets/cursor.dart'; |
|
import 'widgets/delegate.dart'; |
|
import 'widgets/float_cursor.dart'; |
|
import 'widgets/text/text_selection.dart'; |
|
|
|
/// Base interface for editable render objects. |
|
abstract class RenderAbstractEditor implements TextLayoutMetrics { |
|
TextSelection selectWordAtPosition(TextPosition position); |
|
|
|
TextSelection selectLineAtPosition(TextPosition position); |
|
|
|
/// Returns preferred line height at specified `position` in text. |
|
double preferredLineHeight(TextPosition position); |
|
|
|
/// Returns [Rect] for caret in local coordinates |
|
/// |
|
/// Useful to enforce visibility of full caret at given position |
|
Rect getLocalRectForCaret(TextPosition position); |
|
|
|
/// Returns the local coordinates of the endpoints of the given selection. |
|
/// |
|
/// If the selection is collapsed (and therefore occupies a single point), the |
|
/// returned list is of length one. Otherwise, the selection is not collapsed |
|
/// and the returned list is of length two. In this case, however, the two |
|
/// points might actually be co-located (e.g., because of a bidirectional |
|
/// selection that contains some text but whose ends meet in the middle). |
|
TextPosition getPositionForOffset(Offset offset); |
|
|
|
/// Returns the local coordinates of the endpoints of the given selection. |
|
/// |
|
/// If the selection is collapsed (and therefore occupies a single point), the |
|
/// returned list is of length one. Otherwise, the selection is not collapsed |
|
/// and the returned list is of length two. In this case, however, the two |
|
/// points might actually be co-located (e.g., because of a bidirectional |
|
/// selection that contains some text but whose ends meet in the middle). |
|
List<TextSelectionPoint> getEndpointsForSelection( |
|
TextSelection textSelection); |
|
|
|
/// Sets the screen position of the floating cursor and the text position |
|
/// closest to the cursor. |
|
/// `resetLerpValue` drives the size of the floating cursor. |
|
/// See [EditorState.floatingCursorResetController]. |
|
void setFloatingCursor(FloatingCursorDragState dragState, |
|
Offset lastBoundedOffset, TextPosition lastTextPosition, |
|
{double? resetLerpValue}); |
|
|
|
/// If [ignorePointer] is false (the default) then this method is called by |
|
/// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] |
|
/// callback. |
|
/// |
|
/// When [ignorePointer] is true, an ancestor widget must respond to tap |
|
/// down events by calling this method. |
|
void handleTapDown(TapDownDetails details); |
|
|
|
/// Selects the set words of a paragraph in a given range of global positions. |
|
/// |
|
/// The first and last endpoints of the selection will always be at the |
|
/// beginning and end of a word respectively. |
|
/// |
|
/// {@macro flutter.rendering.editable.select} |
|
void selectWordsInRange( |
|
Offset from, |
|
Offset to, |
|
SelectionChangedCause cause, |
|
); |
|
|
|
/// Move the selection to the beginning or end of a word. |
|
/// |
|
/// {@macro flutter.rendering.editable.select} |
|
void selectWordEdge(SelectionChangedCause cause); |
|
|
|
/// |
|
/// Returns the new selection. Note that the returned value may not be |
|
/// yet reflected in the latest widget state. |
|
/// |
|
/// Returns null if no change occurred. |
|
TextSelection? selectPositionAt( |
|
{required Offset from, required SelectionChangedCause cause, Offset? to}); |
|
|
|
/// Select a word around the location of the last tap down. |
|
/// |
|
/// {@macro flutter.rendering.editable.select} |
|
void selectWord(SelectionChangedCause cause); |
|
|
|
/// Move selection to the location of the last tap down. |
|
/// |
|
/// {@template flutter.rendering.editable.select} |
|
/// This method is mainly used to translate user inputs in global positions |
|
/// into a [TextSelection]. When used in conjunction with a [EditableText], |
|
/// the selection change is fed back into [TextEditingController.selection]. |
|
/// |
|
/// If you have a [TextEditingController], it's generally easier to |
|
/// programmatically manipulate its `value` or `selection` directly. |
|
/// {@endtemplate} |
|
void selectPosition({required SelectionChangedCause cause}); |
|
} |
|
|
|
class QuillEditor extends StatefulWidget { |
|
const QuillEditor({ |
|
required this.configurations, |
|
required this.focusNode, |
|
required this.scrollController, |
|
super.key, |
|
}); |
|
|
|
factory QuillEditor.basic({ |
|
/// The configurations for the quill editor widget of flutter quill |
|
required QuillEditorConfigurations configurations, |
|
FocusNode? focusNode, |
|
ScrollController? scrollController, |
|
}) { |
|
return QuillEditor( |
|
scrollController: scrollController ?? ScrollController(), |
|
focusNode: focusNode ?? FocusNode(), |
|
configurations: configurations.copyWith( |
|
textSelectionThemeData: configurations.textSelectionThemeData, |
|
autoFocus: configurations.autoFocus, |
|
expands: configurations.expands, |
|
padding: configurations.padding, |
|
keyboardAppearance: configurations.keyboardAppearance, |
|
embedBuilders: configurations.embedBuilders, |
|
editorKey: configurations.editorKey, |
|
), |
|
); |
|
} |
|
|
|
/// The configurations for the quill editor widget of flutter quill |
|
final QuillEditorConfigurations configurations; |
|
|
|
/// Controls whether this editor has keyboard focus. |
|
final FocusNode focusNode; |
|
|
|
/// The [ScrollController] to use when vertically scrolling the contents. |
|
final ScrollController scrollController; |
|
|
|
@override |
|
QuillEditorState createState() => QuillEditorState(); |
|
} |
|
|
|
class QuillEditorState extends State<QuillEditor> |
|
implements EditorTextSelectionGestureDetectorBuilderDelegate { |
|
late GlobalKey<EditorState> _editorKey; |
|
late EditorTextSelectionGestureDetectorBuilder |
|
_selectionGestureDetectorBuilder; |
|
|
|
QuillEditorConfigurations get configurations { |
|
return widget.configurations; |
|
} |
|
|
|
@override |
|
void initState() { |
|
super.initState(); |
|
widget.configurations.controller.editorFocusNode ??= widget.focusNode; |
|
if (configurations.autoFocus) { |
|
widget.configurations.controller.editorFocusNode?.requestFocus(); |
|
} |
|
_editorKey = configurations.editorKey ?? GlobalKey<EditorState>(); |
|
_selectionGestureDetectorBuilder = |
|
_QuillEditorSelectionGestureDetectorBuilder( |
|
this, |
|
configurations.detectWordBoundary, |
|
); |
|
} |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
final theme = Theme.of(context); |
|
final selectionTheme = |
|
configurations.textSelectionThemeData ?? TextSelectionTheme.of(context); |
|
|
|
TextSelectionControls textSelectionControls; |
|
bool paintCursorAboveText; |
|
bool cursorOpacityAnimates; |
|
Offset? cursorOffset; |
|
Color? cursorColor; |
|
Color selectionColor; |
|
Radius? cursorRadius; |
|
|
|
if (isAppleOS( |
|
platform: theme.platform, |
|
supportWeb: true, |
|
)) { |
|
final cupertinoTheme = CupertinoTheme.of(context); |
|
textSelectionControls = cupertinoTextSelectionControls; |
|
paintCursorAboveText = true; |
|
cursorOpacityAnimates = true; |
|
cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; |
|
selectionColor = selectionTheme.selectionColor ?? |
|
cupertinoTheme.primaryColor.withOpacity(0.40); |
|
cursorRadius ??= const Radius.circular(2); |
|
cursorOffset = Offset( |
|
iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); |
|
} else { |
|
textSelectionControls = materialTextSelectionControls; |
|
paintCursorAboveText = false; |
|
cursorOpacityAnimates = false; |
|
cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; |
|
selectionColor = selectionTheme.selectionColor ?? |
|
theme.colorScheme.primary.withOpacity(0.40); |
|
} |
|
|
|
final showSelectionToolbar = configurations.enableInteractiveSelection && |
|
configurations.enableSelectionToolbar; |
|
|
|
final child = FlutterQuillLocalizationsWidget( |
|
child: QuillEditorProvider( |
|
editorConfigurations: configurations, |
|
child: QuillEditorBuilderWidget( |
|
builder: configurations.builder, |
|
child: QuillRawEditor( |
|
key: _editorKey, |
|
configurations: QuillRawEditorConfigurations( |
|
controller: configurations.controller, |
|
focusNode: widget.focusNode, |
|
scrollController: widget.scrollController, |
|
scrollable: configurations.scrollable, |
|
enableMarkdownStyleConversion: |
|
configurations.enableMarkdownStyleConversion, |
|
scrollBottomInset: configurations.scrollBottomInset, |
|
padding: configurations.padding, |
|
readOnly: configurations.readOnly, |
|
checkBoxReadOnly: configurations.checkBoxReadOnly, |
|
disableClipboard: configurations.disableClipboard, |
|
placeholder: configurations.placeholder, |
|
onLaunchUrl: configurations.onLaunchUrl, |
|
contextMenuBuilder: showSelectionToolbar |
|
? (configurations.contextMenuBuilder ?? |
|
QuillRawEditorConfigurations.defaultContextMenuBuilder) |
|
: null, |
|
showSelectionHandles: isMobile( |
|
platform: theme.platform, |
|
supportWeb: true, |
|
), |
|
showCursor: configurations.showCursor ?? true, |
|
cursorStyle: CursorStyle( |
|
color: cursorColor, |
|
backgroundColor: Colors.grey, |
|
width: 2, |
|
radius: cursorRadius, |
|
offset: cursorOffset, |
|
paintAboveText: |
|
configurations.paintCursorAboveText ?? paintCursorAboveText, |
|
opacityAnimates: cursorOpacityAnimates, |
|
), |
|
textCapitalization: configurations.textCapitalization, |
|
minHeight: configurations.minHeight, |
|
maxHeight: configurations.maxHeight, |
|
maxContentWidth: configurations.maxContentWidth, |
|
customStyles: configurations.customStyles, |
|
expands: configurations.expands, |
|
autoFocus: configurations.autoFocus, |
|
selectionColor: selectionColor, |
|
selectionCtrls: |
|
configurations.textSelectionControls ?? textSelectionControls, |
|
keyboardAppearance: configurations.keyboardAppearance, |
|
enableInteractiveSelection: |
|
configurations.enableInteractiveSelection, |
|
scrollPhysics: configurations.scrollPhysics, |
|
embedBuilder: _getEmbedBuilder, |
|
linkActionPickerDelegate: configurations.linkActionPickerDelegate, |
|
customStyleBuilder: configurations.customStyleBuilder, |
|
customRecognizerBuilder: configurations.customRecognizerBuilder, |
|
floatingCursorDisabled: configurations.floatingCursorDisabled, |
|
onImagePaste: configurations.onImagePaste, |
|
onGifPaste: configurations.onGifPaste, |
|
customShortcuts: configurations.customShortcuts, |
|
customActions: configurations.customActions, |
|
customLinkPrefixes: configurations.customLinkPrefixes, |
|
isOnTapOutsideEnabled: configurations.isOnTapOutsideEnabled, |
|
onTapOutside: configurations.onTapOutside, |
|
dialogTheme: configurations.dialogTheme, |
|
contentInsertionConfiguration: |
|
configurations.contentInsertionConfiguration, |
|
enableScribble: configurations.enableScribble, |
|
onScribbleActivated: configurations.onScribbleActivated, |
|
scribbleAreaInsets: configurations.scribbleAreaInsets, |
|
readOnlyMouseCursor: configurations.readOnlyMouseCursor, |
|
magnifierConfiguration: configurations.magnifierConfiguration, |
|
), |
|
), |
|
), |
|
), |
|
); |
|
|
|
final editor = selectionEnabled |
|
? _selectionGestureDetectorBuilder.build( |
|
behavior: HitTestBehavior.translucent, |
|
detectWordBoundary: configurations.detectWordBoundary, |
|
child: child, |
|
) |
|
: child; |
|
|
|
if (isWeb()) { |
|
// Intercept RawKeyEvent on Web to prevent it from propagating to parents |
|
// that might interfere with the editor key behavior, such as |
|
// SingleChildScrollView. Thanks to @wliumelb for the workaround. |
|
// See issue https://github.com/singerdmx/flutter-quill/issues/304 |
|
return KeyboardListener( |
|
onKeyEvent: (_) {}, |
|
focusNode: FocusNode( |
|
onKeyEvent: (node, event) => KeyEventResult.skipRemainingHandlers, |
|
), |
|
child: editor, |
|
); |
|
} |
|
|
|
return editor; |
|
} |
|
|
|
EmbedBuilder _getEmbedBuilder(Embed node) { |
|
final builders = configurations.embedBuilders; |
|
|
|
if (builders != null) { |
|
for (final builder in builders) { |
|
if (builder.key == node.value.type) { |
|
return builder; |
|
} |
|
} |
|
} |
|
|
|
final unknownEmbedBuilder = configurations.unknownEmbedBuilder; |
|
if (unknownEmbedBuilder != null) { |
|
return unknownEmbedBuilder; |
|
} |
|
|
|
throw UnimplementedError( |
|
'Embeddable type "${node.value.type}" is not supported by supplied ' |
|
'embed builders. You must pass your own builder function to ' |
|
'embedBuilders property of QuillEditor or QuillField widgets or ' |
|
'specify an unknownEmbedBuilder.', |
|
); |
|
} |
|
|
|
@override |
|
GlobalKey<EditorState> get editableTextKey => _editorKey; |
|
|
|
@override |
|
bool get forcePressEnabled => false; |
|
|
|
@override |
|
bool get selectionEnabled => configurations.enableInteractiveSelection; |
|
|
|
void _requestKeyboard() { |
|
final editorCurrentState = _editorKey.currentState; |
|
if (editorCurrentState == null) { |
|
throw ArgumentError.notNull( |
|
'To request keyboard the editor key must not be null', |
|
); |
|
} |
|
editorCurrentState.requestKeyboard(); |
|
} |
|
} |
|
|
|
class _QuillEditorSelectionGestureDetectorBuilder |
|
extends EditorTextSelectionGestureDetectorBuilder { |
|
_QuillEditorSelectionGestureDetectorBuilder( |
|
this._state, this._detectWordBoundary) |
|
: super(delegate: _state, detectWordBoundary: _detectWordBoundary); |
|
|
|
final QuillEditorState _state; |
|
final bool _detectWordBoundary; |
|
|
|
@override |
|
void onForcePressStart(ForcePressDetails details) { |
|
super.onForcePressStart(details); |
|
if (delegate.selectionEnabled && shouldShowSelectionToolbar) { |
|
editor!.showToolbar(); |
|
} |
|
} |
|
|
|
@override |
|
void onForcePressEnd(ForcePressDetails details) {} |
|
|
|
@override |
|
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { |
|
if (_state.configurations.onSingleLongTapMoveUpdate != null) { |
|
if (renderEditor != null && |
|
_state.configurations.onSingleLongTapMoveUpdate!( |
|
details, |
|
renderEditor!.getPositionForOffset, |
|
)) { |
|
return; |
|
} |
|
} |
|
if (!delegate.selectionEnabled) { |
|
return; |
|
} |
|
|
|
final platform = Theme.of(_state.context).platform; |
|
if (isAppleOS( |
|
platform: platform, |
|
supportWeb: true, |
|
)) { |
|
renderEditor!.selectPositionAt( |
|
from: details.globalPosition, |
|
cause: SelectionChangedCause.longPress, |
|
); |
|
} else { |
|
renderEditor!.selectWordsInRange( |
|
details.globalPosition - details.offsetFromOrigin, |
|
details.globalPosition, |
|
SelectionChangedCause.longPress, |
|
); |
|
} |
|
editor?.updateMagnifier(details.globalPosition); |
|
} |
|
|
|
bool _isPositionSelected(TapUpDetails details) { |
|
if (_state.configurations.controller.document.isEmpty()) { |
|
return false; |
|
} |
|
final pos = renderEditor!.getPositionForOffset(details.globalPosition); |
|
final result = editor!.widget.configurations.controller.document |
|
.querySegmentLeafNode(pos.offset); |
|
final line = result.line; |
|
if (line == null) { |
|
return false; |
|
} |
|
final segmentLeaf = result.leaf; |
|
if (segmentLeaf == null && line.length == 1) { |
|
editor!.widget.configurations.controller.updateSelection( |
|
TextSelection.collapsed(offset: pos.offset), |
|
ChangeSource.local, |
|
); |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
@override |
|
void onTapDown(TapDownDetails details) { |
|
if (_state.configurations.onTapDown != null) { |
|
if (renderEditor != null && |
|
_state.configurations.onTapDown!( |
|
details, |
|
renderEditor!.getPositionForOffset, |
|
)) { |
|
return; |
|
} |
|
} |
|
super.onTapDown(details); |
|
} |
|
|
|
bool isShiftClick(PointerDeviceKind deviceKind) { |
|
final pressed = HardwareKeyboard.instance.logicalKeysPressed; |
|
return deviceKind == PointerDeviceKind.mouse && |
|
(pressed.contains(LogicalKeyboardKey.shiftLeft) || |
|
pressed.contains(LogicalKeyboardKey.shiftRight)); |
|
} |
|
|
|
@override |
|
void onSingleTapUp(TapUpDetails details) { |
|
if (_state.configurations.onTapUp != null && |
|
renderEditor != null && |
|
_state.configurations.onTapUp!( |
|
details, |
|
renderEditor!.getPositionForOffset, |
|
)) { |
|
return; |
|
} |
|
|
|
editor!.hideToolbar(); |
|
|
|
try { |
|
if (delegate.selectionEnabled && !_isPositionSelected(details)) { |
|
final platform = Theme.of(_state.context).platform; |
|
if (isAppleOS(platform: platform, supportWeb: true) || |
|
isDesktop(platform: platform, supportWeb: true)) { |
|
// added isDesktop() to enable extend selection in Windows platform |
|
switch (details.kind) { |
|
case PointerDeviceKind.mouse: |
|
case PointerDeviceKind.stylus: |
|
case PointerDeviceKind.invertedStylus: |
|
// Precise devices should place the cursor at a precise position. |
|
// If `Shift` key is pressed then |
|
// extend current selection instead. |
|
if (isShiftClick(details.kind)) { |
|
renderEditor! |
|
..extendSelection(details.globalPosition, |
|
cause: SelectionChangedCause.tap) |
|
..onSelectionCompleted(); |
|
} else { |
|
renderEditor! |
|
..selectPosition(cause: SelectionChangedCause.tap) |
|
..onSelectionCompleted(); |
|
} |
|
|
|
break; |
|
case PointerDeviceKind.touch: |
|
case PointerDeviceKind.unknown: |
|
// On macOS/iOS/iPadOS a touch tap places the cursor at the edge |
|
// of the word. |
|
if (_detectWordBoundary) { |
|
renderEditor! |
|
..selectWordEdge(SelectionChangedCause.tap) |
|
..onSelectionCompleted(); |
|
} else { |
|
renderEditor! |
|
..selectPosition(cause: SelectionChangedCause.tap) |
|
..onSelectionCompleted(); |
|
} |
|
break; |
|
case PointerDeviceKind.trackpad: |
|
// TODO: Handle this case. |
|
break; |
|
} |
|
} else { |
|
renderEditor! |
|
..selectPosition(cause: SelectionChangedCause.tap) |
|
..onSelectionCompleted(); |
|
} |
|
} |
|
} finally { |
|
_state._requestKeyboard(); |
|
} |
|
} |
|
|
|
@override |
|
void onSingleLongTapStart(LongPressStartDetails details) { |
|
if (_state.configurations.onSingleLongTapStart != null) { |
|
if (renderEditor != null && |
|
_state.configurations.onSingleLongTapStart!( |
|
details, |
|
renderEditor!.getPositionForOffset, |
|
)) { |
|
return; |
|
} |
|
} |
|
|
|
if (delegate.selectionEnabled) { |
|
final platform = Theme.of(_state.context).platform; |
|
if (isAppleOS( |
|
platform: platform, |
|
supportWeb: true, |
|
)) { |
|
renderEditor!.selectPositionAt( |
|
from: details.globalPosition, |
|
cause: SelectionChangedCause.longPress, |
|
); |
|
} else { |
|
renderEditor!.selectWord(SelectionChangedCause.longPress); |
|
Feedback.forLongPress(_state.context); |
|
} |
|
} |
|
|
|
_showMagnifierIfSupportedByPlatform(details.globalPosition); |
|
} |
|
|
|
@override |
|
void onSingleLongTapEnd(LongPressEndDetails details) { |
|
if (_state.configurations.onSingleLongTapEnd != null) { |
|
if (renderEditor != null) { |
|
if (_state.configurations.onSingleLongTapEnd!( |
|
details, |
|
renderEditor!.getPositionForOffset, |
|
)) { |
|
return; |
|
} |
|
|
|
if (delegate.selectionEnabled) { |
|
renderEditor!.onSelectionCompleted(); |
|
} |
|
} |
|
} |
|
_hideMagnifierIfSupportedByPlatform(); |
|
super.onSingleLongTapEnd(details); |
|
} |
|
|
|
void _showMagnifierIfSupportedByPlatform(Offset positionToShow) { |
|
switch (defaultTargetPlatform) { |
|
case TargetPlatform.android: |
|
case TargetPlatform.iOS: |
|
editor?.showMagnifier(positionToShow); |
|
default: |
|
} |
|
} |
|
|
|
void _hideMagnifierIfSupportedByPlatform() { |
|
switch (defaultTargetPlatform) { |
|
case TargetPlatform.android: |
|
case TargetPlatform.iOS: |
|
editor?.hideMagnifier(); |
|
default: |
|
} |
|
} |
|
} |
|
|
|
/// Signature for the callback that reports when the user changes the selection |
|
/// (including the cursor location). |
|
/// |
|
/// Used by [RenderEditor.onSelectionChanged]. |
|
typedef TextSelectionChangedHandler = void Function( |
|
TextSelection selection, SelectionChangedCause cause); |
|
|
|
/// Signature for the callback that reports when a selection action is actually |
|
/// completed and ratified. Completion is defined as when the user input has |
|
/// concluded for an entire selection action. For simple taps and keyboard input |
|
/// events that change the selection, this callback is invoked immediately |
|
/// following the TextSelectionChangedHandler. For long taps, the selection is |
|
/// considered complete at the up event of a long tap. For drag selections, the |
|
/// selection completes once the drag/pan event ends or is interrupted. |
|
/// |
|
/// Used by [RenderEditor.onSelectionCompleted]. |
|
typedef TextSelectionCompletedHandler = void Function(); |
|
|
|
// The padding applied to text field. Used to determine the bounds when |
|
// moving the floating cursor. |
|
const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5); |
|
|
|
// The additional size on the x and y axis with which to expand the prototype |
|
// cursor to render the floating cursor in pixels. |
|
const EdgeInsets _kFloatingCaretSizeIncrease = |
|
EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); |
|
|
|
/// Displays a document as a vertical list of document segments (lines |
|
/// and blocks). |
|
/// |
|
/// Children of [RenderEditor] must be instances of [RenderEditableBox]. |
|
class RenderEditor extends RenderEditableContainerBox |
|
with RelayoutWhenSystemFontsChangeMixin |
|
implements RenderAbstractEditor { |
|
RenderEditor({ |
|
required this.document, |
|
required super.textDirection, |
|
required bool hasFocus, |
|
required this.selection, |
|
required this.scrollable, |
|
required LayerLink startHandleLayerLink, |
|
required LayerLink endHandleLayerLink, |
|
required super.padding, |
|
required CursorCont cursorController, |
|
required this.onSelectionChanged, |
|
required this.onSelectionCompleted, |
|
required super.scrollBottomInset, |
|
required this.floatingCursorDisabled, |
|
ViewportOffset? offset, |
|
super.children, |
|
EdgeInsets floatingCursorAddedMargin = |
|
const EdgeInsets.fromLTRB(4, 4, 4, 5), |
|
double? maxContentWidth, |
|
}) : _hasFocus = hasFocus, |
|
_extendSelectionOrigin = selection, |
|
_startHandleLayerLink = startHandleLayerLink, |
|
_endHandleLayerLink = endHandleLayerLink, |
|
_cursorController = cursorController, |
|
_maxContentWidth = maxContentWidth, |
|
super( |
|
container: document.root, |
|
); |
|
|
|
final CursorCont _cursorController; |
|
final bool floatingCursorDisabled; |
|
final bool scrollable; |
|
|
|
Document document; |
|
TextSelection selection; |
|
bool _hasFocus = false; |
|
LayerLink _startHandleLayerLink; |
|
LayerLink _endHandleLayerLink; |
|
|
|
/// Called when the selection changes. |
|
TextSelectionChangedHandler onSelectionChanged; |
|
TextSelectionCompletedHandler onSelectionCompleted; |
|
final ValueNotifier<bool> _selectionStartInViewport = |
|
ValueNotifier<bool>(true); |
|
|
|
ValueListenable<bool> get selectionStartInViewport => |
|
_selectionStartInViewport; |
|
|
|
ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport; |
|
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true); |
|
|
|
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 |
|
// reported as having a slightly (< 0.5) negative y offset. This rounding |
|
// happens in paragraph.cc's layout and TextPainer's |
|
// _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 endOffset = _getOffsetForCaret(endPosition); |
|
_selectionEndInViewport.value = visibleRegion |
|
.inflate(visibleRegionSlop) |
|
.contains(endOffset + effectiveOffset); |
|
} |
|
|
|
// returns offset relative to this at which the caret will be painted |
|
// given a global TextPosition |
|
Offset _getOffsetForCaret(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final childPosition = child.globalToLocalPosition(position); |
|
final boxParentData = child.parentData as BoxParentData; |
|
final localOffsetForCaret = child.getOffsetForCaret(childPosition); |
|
return boxParentData.offset + localOffsetForCaret; |
|
} |
|
|
|
void setDocument(Document doc) { |
|
if (document == doc) { |
|
return; |
|
} |
|
document = doc; |
|
markNeedsLayout(); |
|
} |
|
|
|
void setHasFocus(bool h) { |
|
if (_hasFocus == h) { |
|
return; |
|
} |
|
_hasFocus = h; |
|
markNeedsSemanticsUpdate(); |
|
} |
|
|
|
Offset get _paintOffset => Offset(0, -(offset?.pixels ?? 0.0)); |
|
|
|
ViewportOffset? get offset => _offset; |
|
ViewportOffset? _offset; |
|
|
|
set offset(ViewportOffset? value) { |
|
if (_offset == value) return; |
|
if (attached) _offset?.removeListener(markNeedsPaint); |
|
_offset = value; |
|
if (attached) _offset?.addListener(markNeedsPaint); |
|
markNeedsLayout(); |
|
} |
|
|
|
void setSelection(TextSelection t) { |
|
if (selection == t) { |
|
return; |
|
} |
|
selection = t; |
|
markNeedsPaint(); |
|
|
|
if (!_shiftPressed && !_isDragging) { |
|
// Only update extend selection origin if Shift key is not pressed and |
|
// user is not dragging selection. |
|
_extendSelectionOrigin = selection; |
|
} |
|
} |
|
|
|
bool get _shiftPressed => |
|
HardwareKeyboard.instance.logicalKeysPressed |
|
.contains(LogicalKeyboardKey.shiftLeft) || |
|
HardwareKeyboard.instance.logicalKeysPressed |
|
.contains(LogicalKeyboardKey.shiftRight); |
|
|
|
void setStartHandleLayerLink(LayerLink value) { |
|
if (_startHandleLayerLink == value) { |
|
return; |
|
} |
|
_startHandleLayerLink = value; |
|
markNeedsPaint(); |
|
} |
|
|
|
void setEndHandleLayerLink(LayerLink value) { |
|
if (_endHandleLayerLink == value) { |
|
return; |
|
} |
|
_endHandleLayerLink = value; |
|
markNeedsPaint(); |
|
} |
|
|
|
void setScrollBottomInset(double value) { |
|
if (scrollBottomInset == value) { |
|
return; |
|
} |
|
scrollBottomInset = value; |
|
markNeedsPaint(); |
|
} |
|
|
|
double? _maxContentWidth; |
|
|
|
set maxContentWidth(double? value) { |
|
if (_maxContentWidth == value) return; |
|
_maxContentWidth = value; |
|
markNeedsLayout(); |
|
} |
|
|
|
@override |
|
List<TextSelectionPoint> getEndpointsForSelection( |
|
TextSelection textSelection) { |
|
if (textSelection.isCollapsed) { |
|
final child = childAtPosition(textSelection.extent); |
|
final localPosition = TextPosition( |
|
offset: textSelection.extentOffset - child.container.offset, |
|
affinity: textSelection.affinity, |
|
); |
|
final localOffset = child.getOffsetForCaret(localPosition); |
|
final parentData = child.parentData as BoxParentData; |
|
return <TextSelectionPoint>[ |
|
TextSelectionPoint( |
|
Offset(0, child.preferredLineHeight(localPosition)) + |
|
localOffset + |
|
parentData.offset, |
|
null) |
|
]; |
|
} |
|
|
|
final baseNode = _container.queryChild(textSelection.start, false).node; |
|
|
|
var baseChild = firstChild; |
|
while (baseChild != null) { |
|
if (baseChild.container == baseNode) { |
|
break; |
|
} |
|
baseChild = childAfter(baseChild); |
|
} |
|
assert(baseChild != null); |
|
|
|
final baseParentData = baseChild!.parentData as BoxParentData; |
|
final baseSelection = |
|
localSelection(baseChild.container, textSelection, true); |
|
var basePoint = baseChild.getBaseEndpointForSelection(baseSelection); |
|
basePoint = TextSelectionPoint( |
|
basePoint.point + baseParentData.offset, |
|
basePoint.direction, |
|
); |
|
|
|
final extentNode = _container.queryChild(textSelection.end, false).node; |
|
RenderEditableBox? extentChild = baseChild; |
|
while (extentChild != null) { |
|
if (extentChild.container == extentNode) { |
|
break; |
|
} |
|
extentChild = childAfter(extentChild); |
|
} |
|
assert(extentChild != null); |
|
|
|
final extentParentData = extentChild!.parentData as BoxParentData; |
|
final extentSelection = |
|
localSelection(extentChild.container, textSelection, true); |
|
var extentPoint = |
|
extentChild.getExtentEndpointForSelection(extentSelection); |
|
extentPoint = TextSelectionPoint( |
|
extentPoint.point + extentParentData.offset, |
|
extentPoint.direction, |
|
); |
|
|
|
return <TextSelectionPoint>[basePoint, extentPoint]; |
|
} |
|
|
|
Offset? _lastTapDownPosition; |
|
|
|
// Used on Desktop (mouse and keyboard enabled platforms) as base offset |
|
// for extending selection, either with combination of `Shift` + Click or |
|
// by dragging |
|
TextSelection? _extendSelectionOrigin; |
|
|
|
@override |
|
void handleTapDown(TapDownDetails details) { |
|
_lastTapDownPosition = details.globalPosition; |
|
} |
|
|
|
bool _isDragging = false; |
|
|
|
void handleDragStart(DragStartDetails details) { |
|
_isDragging = true; |
|
|
|
final newSelection = selectPositionAt( |
|
from: details.globalPosition, |
|
cause: SelectionChangedCause.drag, |
|
); |
|
|
|
if (newSelection == null) return; |
|
// Make sure to remember the origin for extend selection. |
|
_extendSelectionOrigin = newSelection; |
|
} |
|
|
|
void handleDragEnd(DragEndDetails details) { |
|
_isDragging = false; |
|
onSelectionCompleted(); |
|
} |
|
|
|
@override |
|
void selectWordsInRange( |
|
Offset from, |
|
Offset? to, |
|
SelectionChangedCause cause, |
|
) { |
|
final firstPosition = getPositionForOffset(from); |
|
final firstWord = selectWordAtPosition(firstPosition); |
|
final lastWord = |
|
to == null ? firstWord : selectWordAtPosition(getPositionForOffset(to)); |
|
|
|
_handleSelectionChange( |
|
TextSelection( |
|
baseOffset: firstWord.base.offset, |
|
extentOffset: lastWord.extent.offset, |
|
affinity: firstWord.affinity, |
|
), |
|
cause, |
|
); |
|
} |
|
|
|
void _handleSelectionChange( |
|
TextSelection nextSelection, |
|
SelectionChangedCause cause, |
|
) { |
|
final focusingEmpty = nextSelection.baseOffset == 0 && |
|
nextSelection.extentOffset == 0 && |
|
!_hasFocus; |
|
if (nextSelection == selection && |
|
cause != SelectionChangedCause.keyboard && |
|
!focusingEmpty) { |
|
return; |
|
} |
|
onSelectionChanged(nextSelection, cause); |
|
} |
|
|
|
/// Extends current selection to the position closest to specified offset. |
|
void extendSelection(Offset to, {required SelectionChangedCause cause}) { |
|
/// The below logic does not exactly match the native version because |
|
/// we do not allow swapping of base and extent positions. |
|
assert(_extendSelectionOrigin != null); |
|
final position = getPositionForOffset(to); |
|
|
|
if (position.offset < _extendSelectionOrigin!.baseOffset) { |
|
_handleSelectionChange( |
|
TextSelection( |
|
baseOffset: position.offset, |
|
extentOffset: _extendSelectionOrigin!.extentOffset, |
|
affinity: selection.affinity, |
|
), |
|
cause, |
|
); |
|
} else if (position.offset > _extendSelectionOrigin!.extentOffset) { |
|
_handleSelectionChange( |
|
TextSelection( |
|
baseOffset: _extendSelectionOrigin!.baseOffset, |
|
extentOffset: position.offset, |
|
affinity: selection.affinity, |
|
), |
|
cause, |
|
); |
|
} |
|
} |
|
|
|
@override |
|
void selectWordEdge(SelectionChangedCause cause) { |
|
assert(_lastTapDownPosition != null); |
|
final position = getPositionForOffset(_lastTapDownPosition!); |
|
final child = childAtPosition(position); |
|
final nodeOffset = child.container.offset; |
|
final localPosition = TextPosition( |
|
offset: position.offset - nodeOffset, |
|
affinity: position.affinity, |
|
); |
|
final localWord = child.getWordBoundary(localPosition); |
|
final word = TextRange( |
|
start: localWord.start + nodeOffset, |
|
end: localWord.end + nodeOffset, |
|
); |
|
if (position.offset - word.start <= 1 && word.end != position.offset) { |
|
_handleSelectionChange( |
|
TextSelection.collapsed(offset: word.start), |
|
cause, |
|
); |
|
} else { |
|
_handleSelectionChange( |
|
TextSelection.collapsed( |
|
offset: word.end, affinity: TextAffinity.upstream), |
|
cause, |
|
); |
|
} |
|
} |
|
|
|
@override |
|
TextSelection? selectPositionAt({ |
|
required Offset from, |
|
required SelectionChangedCause cause, |
|
Offset? to, |
|
}) { |
|
final fromPosition = getPositionForOffset(from); |
|
final toPosition = to == null ? null : getPositionForOffset(to); |
|
|
|
var baseOffset = fromPosition.offset; |
|
var extentOffset = fromPosition.offset; |
|
if (toPosition != null) { |
|
baseOffset = math.min(fromPosition.offset, toPosition.offset); |
|
extentOffset = math.max(fromPosition.offset, toPosition.offset); |
|
} |
|
|
|
final newSelection = TextSelection( |
|
baseOffset: baseOffset, |
|
extentOffset: extentOffset, |
|
affinity: fromPosition.affinity, |
|
); |
|
|
|
// Call [onSelectionChanged] only when the selection actually changed. |
|
_handleSelectionChange(newSelection, cause); |
|
return newSelection; |
|
} |
|
|
|
@override |
|
void selectWord(SelectionChangedCause cause) { |
|
selectWordsInRange(_lastTapDownPosition!, null, cause); |
|
} |
|
|
|
@override |
|
void selectPosition({required SelectionChangedCause cause}) { |
|
selectPositionAt(from: _lastTapDownPosition!, cause: cause); |
|
} |
|
|
|
@override |
|
TextSelection selectWordAtPosition(TextPosition position) { |
|
final word = getWordBoundary(position); |
|
// When long-pressing past the end of the text, we want a collapsed cursor. |
|
if (position.offset >= word.end) { |
|
return TextSelection.fromPosition(position); |
|
} |
|
return TextSelection(baseOffset: word.start, extentOffset: word.end); |
|
} |
|
|
|
@override |
|
TextSelection selectLineAtPosition(TextPosition position) { |
|
final line = getLineAtOffset(position); |
|
|
|
// When long-pressing past the end of the text, we want a collapsed cursor. |
|
if (position.offset >= line.end) { |
|
return TextSelection.fromPosition(position); |
|
} |
|
return TextSelection(baseOffset: line.start, extentOffset: line.end); |
|
} |
|
|
|
@override |
|
void performLayout() { |
|
assert(() { |
|
if (!scrollable || !constraints.hasBoundedHeight) return true; |
|
throw FlutterError.fromParts(<DiagnosticsNode>[ |
|
ErrorSummary('RenderEditableContainerBox must have ' |
|
'unlimited space along its main axis when it is scrollable.'), |
|
ErrorDescription('RenderEditableContainerBox does not clip or' |
|
' resize its children, so it must be ' |
|
'placed in a parent that does not constrain the main ' |
|
'axis.'), |
|
ErrorHint( |
|
'You probably want to put the RenderEditableContainerBox inside a ' |
|
'RenderViewport with a matching main axis or disable the ' |
|
'scrollable property.') |
|
]); |
|
}()); |
|
assert(() { |
|
if (constraints.hasBoundedWidth) return true; |
|
throw FlutterError.fromParts(<DiagnosticsNode>[ |
|
ErrorSummary('RenderEditableContainerBox must have a bounded' |
|
' constraint for its cross axis.'), |
|
ErrorDescription('RenderEditableContainerBox forces its children to ' |
|
"expand to fit the RenderEditableContainerBox's container, " |
|
'so it must be placed in a parent that constrains the cross ' |
|
'axis to a finite dimension.'), |
|
]); |
|
}()); |
|
|
|
resolvePadding(); |
|
assert(resolvedPadding != null); |
|
|
|
var mainAxisExtent = resolvedPadding!.top; |
|
var child = firstChild; |
|
final innerConstraints = BoxConstraints.tightFor( |
|
width: math.min( |
|
_maxContentWidth ?? double.infinity, constraints.maxWidth)) |
|
.deflate(resolvedPadding!); |
|
final leftOffset = _maxContentWidth == null |
|
? 0.0 |
|
: math.max((constraints.maxWidth - _maxContentWidth!) / 2, 0); |
|
while (child != null) { |
|
child.layout(innerConstraints, parentUsesSize: true); |
|
final childParentData = child.parentData as EditableContainerParentData |
|
..offset = Offset(resolvedPadding!.left + leftOffset, mainAxisExtent); |
|
mainAxisExtent += child.size.height; |
|
assert(child.parentData == childParentData); |
|
child = childParentData.nextSibling; |
|
} |
|
mainAxisExtent += resolvedPadding!.bottom; |
|
size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); |
|
|
|
assert(size.isFinite); |
|
} |
|
|
|
@override |
|
void paint(PaintingContext context, Offset offset) { |
|
if (_hasFocus && |
|
_cursorController.show.value && |
|
!_cursorController.style.paintAboveText) { |
|
_paintFloatingCursor(context, offset); |
|
} |
|
defaultPaint(context, offset); |
|
_updateSelectionExtentsVisibility(offset + _paintOffset); |
|
_paintHandleLayers(context, getEndpointsForSelection(selection)); |
|
|
|
if (_hasFocus && |
|
_cursorController.show.value && |
|
_cursorController.style.paintAboveText) { |
|
_paintFloatingCursor(context, offset); |
|
} |
|
} |
|
|
|
@override |
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
|
return defaultHitTestChildren(result, position: position); |
|
} |
|
|
|
void _paintHandleLayers( |
|
PaintingContext context, List<TextSelectionPoint> endpoints) { |
|
var startPoint = endpoints[0].point; |
|
startPoint = Offset( |
|
startPoint.dx.clamp(0.0, size.width), |
|
startPoint.dy.clamp(0.0, size.height), |
|
); |
|
context.pushLayer( |
|
LeaderLayer(link: _startHandleLayerLink, offset: startPoint), |
|
super.paint, |
|
Offset.zero, |
|
); |
|
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), |
|
); |
|
context.pushLayer( |
|
LeaderLayer(link: _endHandleLayerLink, offset: endPoint), |
|
super.paint, |
|
Offset.zero, |
|
); |
|
} |
|
} |
|
|
|
@override |
|
double preferredLineHeight(TextPosition position) { |
|
final child = childAtPosition(position); |
|
return child.preferredLineHeight( |
|
TextPosition(offset: position.offset - child.container.offset)); |
|
} |
|
|
|
@override |
|
TextPosition getPositionForOffset(Offset offset) { |
|
final local = globalToLocal(offset); |
|
final child = childAtOffset(local); |
|
|
|
final parentData = child.parentData as BoxParentData; |
|
final localOffset = local - parentData.offset; |
|
final localPosition = child.getPositionForOffset(localOffset); |
|
return TextPosition( |
|
offset: localPosition.offset + child.container.offset, |
|
affinity: localPosition.affinity, |
|
); |
|
} |
|
|
|
/// 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); |
|
final localPosition = targetChild.globalToLocalPosition(position); |
|
|
|
final childLocalRect = targetChild.getLocalRectForCaret(localPosition); |
|
|
|
final boxParentData = targetChild.parentData as BoxParentData; |
|
return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); |
|
} |
|
|
|
// Start floating cursor |
|
|
|
FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( |
|
floatingCursorRect: _floatingCursorRect, |
|
style: _cursorController.style, |
|
); |
|
|
|
bool _floatingCursorOn = false; |
|
Rect? _floatingCursorRect; |
|
|
|
TextPosition get floatingCursorTextPosition => _floatingCursorTextPosition; |
|
late TextPosition _floatingCursorTextPosition; |
|
|
|
// The relative origin in relation to the distance the user has theoretically |
|
// dragged the floating cursor offscreen. |
|
// This value is used to account for the difference |
|
// in the rendering position and the raw offset value. |
|
Offset _relativeOrigin = Offset.zero; |
|
Offset? _previousOffset; |
|
bool _resetOriginOnLeft = false; |
|
bool _resetOriginOnRight = false; |
|
bool _resetOriginOnTop = false; |
|
bool _resetOriginOnBottom = false; |
|
|
|
/// Returns the position within the editor closest to the raw cursor offset. |
|
Offset calculateBoundedFloatingCursorOffset( |
|
Offset rawCursorOffset, double preferredLineHeight) { |
|
var deltaPosition = Offset.zero; |
|
final topBound = _kFloatingCursorAddedMargin.top; |
|
final bottomBound = |
|
size.height - preferredLineHeight + _kFloatingCursorAddedMargin.bottom; |
|
final leftBound = _kFloatingCursorAddedMargin.left; |
|
final rightBound = size.width - _kFloatingCursorAddedMargin.right; |
|
|
|
if (_previousOffset != null) { |
|
deltaPosition = rawCursorOffset - _previousOffset!; |
|
} |
|
|
|
// If the raw cursor offset has gone off an edge, |
|
// we want to reset the relative origin of |
|
// the dragging when the user drags back into the field. |
|
if (_resetOriginOnLeft && deltaPosition.dx > 0) { |
|
_relativeOrigin = |
|
Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); |
|
_resetOriginOnLeft = false; |
|
} else if (_resetOriginOnRight && deltaPosition.dx < 0) { |
|
_relativeOrigin = |
|
Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); |
|
_resetOriginOnRight = false; |
|
} |
|
if (_resetOriginOnTop && deltaPosition.dy > 0) { |
|
_relativeOrigin = |
|
Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); |
|
_resetOriginOnTop = false; |
|
} else if (_resetOriginOnBottom && deltaPosition.dy < 0) { |
|
_relativeOrigin = |
|
Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); |
|
_resetOriginOnBottom = false; |
|
} |
|
|
|
final currentX = rawCursorOffset.dx - _relativeOrigin.dx; |
|
final currentY = rawCursorOffset.dy - _relativeOrigin.dy; |
|
final double adjustedX = |
|
math.min(math.max(currentX, leftBound), rightBound); |
|
final double adjustedY = |
|
math.min(math.max(currentY, topBound), bottomBound); |
|
final adjustedOffset = Offset(adjustedX, adjustedY); |
|
|
|
if (currentX < leftBound && deltaPosition.dx < 0) { |
|
_resetOriginOnLeft = true; |
|
} else if (currentX > rightBound && deltaPosition.dx > 0) { |
|
_resetOriginOnRight = true; |
|
} |
|
if (currentY < topBound && deltaPosition.dy < 0) { |
|
_resetOriginOnTop = true; |
|
} else if (currentY > bottomBound && deltaPosition.dy > 0) { |
|
_resetOriginOnBottom = true; |
|
} |
|
|
|
_previousOffset = rawCursorOffset; |
|
|
|
return adjustedOffset; |
|
} |
|
|
|
@override |
|
void setFloatingCursor(FloatingCursorDragState dragState, |
|
Offset boundedOffset, TextPosition textPosition, |
|
{double? resetLerpValue}) { |
|
if (floatingCursorDisabled) return; |
|
|
|
if (dragState == FloatingCursorDragState.Start) { |
|
_relativeOrigin = Offset.zero; |
|
_previousOffset = null; |
|
_resetOriginOnBottom = false; |
|
_resetOriginOnTop = false; |
|
_resetOriginOnRight = false; |
|
_resetOriginOnBottom = false; |
|
} |
|
_floatingCursorOn = dragState != FloatingCursorDragState.End; |
|
if (_floatingCursorOn) { |
|
_floatingCursorTextPosition = textPosition; |
|
final sizeAdjustment = resetLerpValue != null |
|
? EdgeInsets.lerp( |
|
_kFloatingCaretSizeIncrease, EdgeInsets.zero, resetLerpValue)! |
|
: _kFloatingCaretSizeIncrease; |
|
final child = childAtPosition(textPosition); |
|
final caretPrototype = |
|
child.getCaretPrototype(child.globalToLocalPosition(textPosition)); |
|
_floatingCursorRect = |
|
sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); |
|
_cursorController |
|
.setFloatingCursorTextPosition(_floatingCursorTextPosition); |
|
} else { |
|
_floatingCursorRect = null; |
|
_cursorController.setFloatingCursorTextPosition(null); |
|
} |
|
} |
|
|
|
void _paintFloatingCursor(PaintingContext context, Offset offset) { |
|
_floatingCursorPainter.paint(context.canvas); |
|
} |
|
|
|
// End floating cursor |
|
|
|
// Start TextLayoutMetrics implementation |
|
|
|
/// Return a [TextSelection] containing the line of the given [TextPosition]. |
|
@override |
|
TextSelection getLineAtOffset(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final nodeOffset = child.container.offset; |
|
final localPosition = TextPosition( |
|
offset: position.offset - nodeOffset, affinity: position.affinity); |
|
final localLineRange = child.getLineBoundary(localPosition); |
|
final line = TextRange( |
|
start: localLineRange.start + nodeOffset, |
|
end: localLineRange.end + nodeOffset, |
|
); |
|
return TextSelection(baseOffset: line.start, extentOffset: line.end); |
|
} |
|
|
|
@override |
|
TextRange getWordBoundary(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final nodeOffset = child.container.offset; |
|
final localPosition = TextPosition( |
|
offset: position.offset - nodeOffset, affinity: position.affinity); |
|
final localWord = child.getWordBoundary(localPosition); |
|
return TextRange( |
|
start: localWord.start + nodeOffset, |
|
end: localWord.end + nodeOffset, |
|
); |
|
} |
|
|
|
/// Returns the TextPosition after moving by the vertical offset. |
|
TextPosition getTextPositionMoveVertical( |
|
TextPosition position, double verticalOffset) { |
|
final caretOfs = localToGlobal(_getOffsetForCaret(position)); |
|
return getPositionForOffset(caretOfs.translate(0, verticalOffset)); |
|
} |
|
|
|
/// Returns the TextPosition above the given offset into the text. |
|
/// |
|
/// If the offset is already on the first line, the offset of the first |
|
/// character will be returned. |
|
@override |
|
TextPosition getTextPositionAbove(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final localPosition = |
|
TextPosition(offset: position.offset - child.container.documentOffset); |
|
|
|
var newPosition = child.getPositionAbove(localPosition); |
|
|
|
if (newPosition == null) { |
|
// There was no text above in the current child, check the direct |
|
// sibling. |
|
final sibling = childBefore(child); |
|
if (sibling == null) { |
|
// reached beginning of the document, move to the |
|
// first character |
|
newPosition = const TextPosition(offset: 0); |
|
} else { |
|
final caretOffset = child.getOffsetForCaret(localPosition); |
|
final testPosition = TextPosition(offset: sibling.container.length - 1); |
|
final testOffset = sibling.getOffsetForCaret(testPosition); |
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
|
final siblingPosition = sibling.getPositionForOffset(finalOffset); |
|
newPosition = TextPosition( |
|
offset: sibling.container.documentOffset + siblingPosition.offset); |
|
} |
|
} else { |
|
newPosition = TextPosition( |
|
offset: child.container.documentOffset + newPosition.offset); |
|
} |
|
return newPosition; |
|
} |
|
|
|
/// Returns the TextPosition below the given offset into the text. |
|
/// |
|
/// If the offset is already on the last line, the offset of the last |
|
/// character will be returned. |
|
@override |
|
TextPosition getTextPositionBelow(TextPosition position) { |
|
final child = childAtPosition(position); |
|
final localPosition = TextPosition( |
|
offset: position.offset - child.container.documentOffset, |
|
); |
|
|
|
var newPosition = child.getPositionBelow(localPosition); |
|
|
|
if (newPosition == null) { |
|
// There was no text below in the current child, check the direct sibling. |
|
final sibling = childAfter(child); |
|
if (sibling == null) { |
|
// reached end of the document, move to the |
|
// last character |
|
newPosition = TextPosition(offset: document.length - 1); |
|
} else { |
|
final caretOffset = child.getOffsetForCaret(localPosition); |
|
const testPosition = TextPosition(offset: 0); |
|
final testOffset = sibling.getOffsetForCaret(testPosition); |
|
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
|
final siblingPosition = sibling.getPositionForOffset(finalOffset); |
|
newPosition = TextPosition( |
|
offset: sibling.container.documentOffset + siblingPosition.offset, |
|
); |
|
} |
|
} else { |
|
newPosition = TextPosition( |
|
offset: child.container.documentOffset + newPosition.offset, |
|
); |
|
} |
|
return newPosition; |
|
} |
|
|
|
// End TextLayoutMetrics implementation |
|
|
|
QuillVerticalCaretMovementRun startVerticalCaretMovement( |
|
TextPosition startPosition) { |
|
return QuillVerticalCaretMovementRun._( |
|
this, |
|
startPosition, |
|
); |
|
} |
|
|
|
@override |
|
void systemFontsDidChange() { |
|
super.systemFontsDidChange(); |
|
markNeedsLayout(); |
|
} |
|
} |
|
|
|
class QuillVerticalCaretMovementRun implements Iterator<TextPosition> { |
|
QuillVerticalCaretMovementRun._( |
|
this._editor, |
|
this._currentTextPosition, |
|
); |
|
|
|
TextPosition _currentTextPosition; |
|
|
|
final RenderEditor _editor; |
|
|
|
@override |
|
TextPosition get current { |
|
return _currentTextPosition; |
|
} |
|
|
|
@override |
|
bool moveNext() { |
|
_currentTextPosition = _editor.getTextPositionBelow(_currentTextPosition); |
|
return true; |
|
} |
|
|
|
bool movePrevious() { |
|
_currentTextPosition = _editor.getTextPositionAbove(_currentTextPosition); |
|
return true; |
|
} |
|
|
|
void moveVertical(double verticalOffset) { |
|
_currentTextPosition = _editor.getTextPositionMoveVertical( |
|
_currentTextPosition, verticalOffset); |
|
} |
|
} |
|
|
|
class EditableContainerParentData |
|
extends ContainerBoxParentData<RenderEditableBox> {} |
|
|
|
/// Multi-child render box of editable content. |
|
/// |
|
/// Common ancestor for [RenderEditor] and [RenderEditableTextBlock]. |
|
class RenderEditableContainerBox extends RenderBox |
|
with |
|
ContainerRenderObjectMixin<RenderEditableBox, |
|
EditableContainerParentData>, |
|
RenderBoxContainerDefaultsMixin<RenderEditableBox, |
|
EditableContainerParentData> { |
|
RenderEditableContainerBox({ |
|
required container_node.QuillContainer container, |
|
required this.textDirection, |
|
required this.scrollBottomInset, |
|
required EdgeInsetsGeometry padding, |
|
List<RenderEditableBox>? children, |
|
}) : assert(padding.isNonNegative), |
|
_container = container, |
|
_padding = padding { |
|
addAll(children); |
|
} |
|
|
|
container_node.QuillContainer _container; |
|
TextDirection textDirection; |
|
EdgeInsetsGeometry _padding; |
|
double scrollBottomInset; |
|
EdgeInsets? _resolvedPadding; |
|
|
|
container_node.QuillContainer get container => _container; |
|
|
|
void setContainer(container_node.QuillContainer c) { |
|
if (_container == c) { |
|
return; |
|
} |
|
_container = c; |
|
markNeedsLayout(); |
|
} |
|
|
|
EdgeInsetsGeometry getPadding() => _padding; |
|
|
|
void setPadding(EdgeInsetsGeometry value) { |
|
assert(value.isNonNegative); |
|
if (_padding == value) { |
|
return; |
|
} |
|
_padding = value; |
|
_markNeedsPaddingResolution(); |
|
} |
|
|
|
EdgeInsets? get resolvedPadding => _resolvedPadding; |
|
|
|
void resolvePadding() { |
|
if (_resolvedPadding != null) { |
|
return; |
|
} |
|
_resolvedPadding = _padding.resolve(textDirection); |
|
_resolvedPadding = _resolvedPadding!.copyWith(left: _resolvedPadding!.left); |
|
|
|
assert(_resolvedPadding!.isNonNegative); |
|
} |
|
|
|
RenderEditableBox childAtPosition(TextPosition position) { |
|
assert(firstChild != null); |
|
final targetNode = container.queryChild(position.offset, false).node; |
|
|
|
var targetChild = firstChild; |
|
while (targetChild != null) { |
|
if (targetChild.container == targetNode) { |
|
break; |
|
} |
|
final newChild = childAfter(targetChild); |
|
if (newChild == null) { |
|
// At start of document fails to find the position |
|
targetChild = childAtOffset(const Offset(0, 0)); |
|
break; |
|
} |
|
targetChild = newChild; |
|
} |
|
if (targetChild == null) { |
|
throw 'targetChild should not be null'; |
|
} |
|
return targetChild; |
|
} |
|
|
|
void _markNeedsPaddingResolution() { |
|
_resolvedPadding = null; |
|
markNeedsLayout(); |
|
} |
|
|
|
/// Returns child of this container located at the specified local `offset`. |
|
/// |
|
/// If `offset` is above this container (offset.dy is negative) returns |
|
/// the first child. Likewise, if `offset` is below this container then |
|
/// returns the last child. |
|
RenderEditableBox childAtOffset(Offset offset) { |
|
assert(firstChild != null); |
|
resolvePadding(); |
|
|
|
if (offset.dy <= _resolvedPadding!.top) { |
|
return firstChild!; |
|
} |
|
if (offset.dy >= size.height - _resolvedPadding!.bottom) { |
|
return lastChild!; |
|
} |
|
|
|
var child = firstChild; |
|
final dx = -offset.dx; |
|
var dy = _resolvedPadding!.top; |
|
while (child != null) { |
|
if (child.size.contains(offset.translate(dx, -dy))) { |
|
return child; |
|
} |
|
dy += child.size.height; |
|
child = childAfter(child); |
|
} |
|
|
|
// this case possible, when editor not scrollable, |
|
// but minHeight > content height and tap was under content |
|
return lastChild!; |
|
} |
|
|
|
@override |
|
void setupParentData(RenderBox child) { |
|
if (child.parentData is EditableContainerParentData) { |
|
return; |
|
} |
|
|
|
child.parentData = EditableContainerParentData(); |
|
} |
|
|
|
@override |
|
void performLayout() { |
|
assert(constraints.hasBoundedWidth); |
|
resolvePadding(); |
|
assert(_resolvedPadding != null); |
|
|
|
var mainAxisExtent = _resolvedPadding!.top; |
|
var child = firstChild; |
|
final innerConstraints = |
|
BoxConstraints.tightFor(width: constraints.maxWidth) |
|
.deflate(_resolvedPadding!); |
|
while (child != null) { |
|
child.layout(innerConstraints, parentUsesSize: true); |
|
final childParentData = (child.parentData as EditableContainerParentData) |
|
..offset = Offset(_resolvedPadding!.left, mainAxisExtent); |
|
mainAxisExtent += child.size.height; |
|
assert(child.parentData == childParentData); |
|
child = childParentData.nextSibling; |
|
} |
|
mainAxisExtent += _resolvedPadding!.bottom; |
|
size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); |
|
|
|
assert(size.isFinite); |
|
} |
|
|
|
double _getIntrinsicCrossAxis(double Function(RenderBox child) childSize) { |
|
var extent = 0.0; |
|
var child = firstChild; |
|
while (child != null) { |
|
extent = math.max(extent, childSize(child)); |
|
final childParentData = child.parentData as EditableContainerParentData; |
|
child = childParentData.nextSibling; |
|
} |
|
return extent; |
|
} |
|
|
|
double _getIntrinsicMainAxis(double Function(RenderBox child) childSize) { |
|
var extent = 0.0; |
|
var child = firstChild; |
|
while (child != null) { |
|
extent += childSize(child); |
|
final childParentData = child.parentData as EditableContainerParentData; |
|
child = childParentData.nextSibling; |
|
} |
|
return extent; |
|
} |
|
|
|
@override |
|
double computeMinIntrinsicWidth(double height) { |
|
resolvePadding(); |
|
return _getIntrinsicCrossAxis((child) { |
|
final childHeight = math.max<double>( |
|
0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); |
|
return child.getMinIntrinsicWidth(childHeight) + |
|
_resolvedPadding!.left + |
|
_resolvedPadding!.right; |
|
}); |
|
} |
|
|
|
@override |
|
double computeMaxIntrinsicWidth(double height) { |
|
resolvePadding(); |
|
return _getIntrinsicCrossAxis((child) { |
|
final childHeight = math.max<double>( |
|
0, height - _resolvedPadding!.top + _resolvedPadding!.bottom); |
|
return child.getMaxIntrinsicWidth(childHeight) + |
|
_resolvedPadding!.left + |
|
_resolvedPadding!.right; |
|
}); |
|
} |
|
|
|
@override |
|
double computeMinIntrinsicHeight(double width) { |
|
resolvePadding(); |
|
return _getIntrinsicMainAxis((child) { |
|
final childWidth = math.max<double>( |
|
0, width - _resolvedPadding!.left + _resolvedPadding!.right); |
|
return child.getMinIntrinsicHeight(childWidth) + |
|
_resolvedPadding!.top + |
|
_resolvedPadding!.bottom; |
|
}); |
|
} |
|
|
|
@override |
|
double computeMaxIntrinsicHeight(double width) { |
|
resolvePadding(); |
|
return _getIntrinsicMainAxis((child) { |
|
final childWidth = math.max<double>( |
|
0, width - _resolvedPadding!.left + _resolvedPadding!.right); |
|
return child.getMaxIntrinsicHeight(childWidth) + |
|
_resolvedPadding!.top + |
|
_resolvedPadding!.bottom; |
|
}); |
|
} |
|
|
|
@override |
|
double computeDistanceToActualBaseline(TextBaseline baseline) { |
|
resolvePadding(); |
|
return defaultComputeDistanceToFirstActualBaseline(baseline)! + |
|
_resolvedPadding!.top; |
|
} |
|
}
|
|
|