import 'dart:convert'; import 'dart:io' as io; import 'dart:math' as math; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/embed.dart'; import '../models/documents/nodes/leaf.dart' as leaf; import '../models/documents/nodes/line.dart'; import '../utils/string_helper.dart'; import 'box.dart'; import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; import 'image.dart'; import 'raw_editor.dart'; import 'text_selection.dart'; import 'video_app.dart'; import 'youtube_video_app.dart'; const linkPrefixes = [ 'mailto:', // email 'tel:', // telephone 'sms:', // SMS 'callto:', 'wtai:', 'market:', 'geopoint:', 'ymsgr:', 'msnim:', 'gtalk:', // Google Talk 'skype:', 'sip:', // Lync 'whatsapp:', 'http' ]; abstract class EditorState extends State { ScrollController get scrollController; TextEditingValue getTextEditingValue(); void setTextEditingValue(TextEditingValue value, SelectionChangedCause cause); RenderEditor? getRenderEditor(); EditorTextSelectionOverlay? getSelectionOverlay(); bool showToolbar(); void hideToolbar(); void requestKeyboard(); bool get readOnly; } /// Base interface for editable render objects. abstract class RenderAbstractEditor { 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); List getEndpointsForSelection( TextSelection textSelection); /// 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); /// Select text between the global positions [from] and [to]. void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); /// 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(SelectionChangedCause cause); } String _standardizeImageUrl(String url) { if (url.contains('base64')) { return url.split(',')[1]; } return url; } bool _isMobile() => io.Platform.isAndroid || io.Platform.isIOS; Widget defaultEmbedBuilder( BuildContext context, leaf.Embed node, bool readOnly) { assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); switch (node.value.type) { case 'image': final imageUrl = _standardizeImageUrl(node.value.data); final style = node.style.attributes['style']; if (_isMobile() && style != null) { final _attrs = parseKeyValuePairs(style.value.toString(), {'mobileWidth', 'mobileHeight', 'mobileMargin', 'mobileAlignment'}); if (_attrs.isNotEmpty) { assert( _attrs['mobileWidth'] != null && _attrs['mobileHeight'] != null, 'mobileWidth and mobileHeight must be specified'); final w = double.parse(_attrs['mobileWidth']!); final h = double.parse(_attrs['mobileHeight']!); final m = _attrs['mobileMargin'] == null ? 0.0 : double.parse(_attrs['mobileMargin']!); final a = getAlignment(_attrs['mobileAlignment']); return Padding( padding: EdgeInsets.all(m), child: imageUrl.startsWith('http') ? Image.network(imageUrl, width: w, height: h, alignment: a) : isBase64(imageUrl) ? Image.memory(base64.decode(imageUrl), width: w, height: h, alignment: a) : Image.file(io.File(imageUrl), width: w, height: h, alignment: a)); } } return imageUrl.startsWith('http') ? Image.network(imageUrl) : isBase64(imageUrl) ? Image.memory(base64.decode(imageUrl)) : Image.file(io.File(imageUrl)); case 'video': final videoUrl = node.value.data; if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { return YoutubeVideoApp( videoUrl: videoUrl, context: context, readOnly: readOnly); } return VideoApp(videoUrl: videoUrl, context: context, readOnly: readOnly); default: throw UnimplementedError( 'Embeddable type "${node.value.type}" is not supported by default ' 'embed builder of QuillEditor. You must pass your own builder function ' 'to embedBuilder property of QuillEditor or QuillField widgets.', ); } } class QuillEditor extends StatefulWidget { const QuillEditor( {required this.controller, required this.focusNode, required this.scrollController, required this.scrollable, required this.padding, required this.autoFocus, required this.readOnly, required this.expands, this.showCursor, this.paintCursorAboveText, this.placeholder, this.enableInteractiveSelection = true, this.scrollBottomInset = 0, this.minHeight, this.maxHeight, this.customStyles, this.textCapitalization = TextCapitalization.sentences, this.keyboardAppearance = Brightness.light, this.scrollPhysics, this.onLaunchUrl, this.onTapDown, this.onTapUp, this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, this.embedBuilder = defaultEmbedBuilder, this.customStyleBuilder, Key? key}); factory QuillEditor.basic({ required QuillController controller, required bool readOnly, Brightness? keyboardAppearance, }) { return QuillEditor( controller: controller, scrollController: ScrollController(), scrollable: true, focusNode: FocusNode(), autoFocus: true, readOnly: readOnly, expands: false, padding: EdgeInsets.zero, keyboardAppearance: keyboardAppearance ?? Brightness.light, ); } final QuillController controller; final FocusNode focusNode; final ScrollController scrollController; final bool scrollable; final double scrollBottomInset; final EdgeInsetsGeometry padding; final bool autoFocus; final bool? showCursor; final bool? paintCursorAboveText; final bool readOnly; final String? placeholder; final bool enableInteractiveSelection; final double? minHeight; final double? maxHeight; final DefaultStyles? customStyles; final bool expands; final TextCapitalization textCapitalization; final Brightness keyboardAppearance; final ScrollPhysics? scrollPhysics; final ValueChanged? onLaunchUrl; // Returns whether gesture is handled final bool Function( TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; // Returns whether gesture is handled final bool Function( TapUpDetails details, TextPosition Function(Offset offset))? onTapUp; // Returns whether gesture is handled final bool Function( LongPressStartDetails details, TextPosition Function(Offset offset))? onSingleLongTapStart; // Returns whether gesture is handled final bool Function(LongPressMoveUpdateDetails details, TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate; // Returns whether gesture is handled final bool Function( LongPressEndDetails details, TextPosition Function(Offset offset))? onSingleLongTapEnd; final EmbedBuilder embedBuilder; final CustomStyleBuilder? customStyleBuilder; @override _QuillEditorState createState() => _QuillEditorState(); } class _QuillEditorState extends State implements EditorTextSelectionGestureDetectorBuilderDelegate { final GlobalKey _editorKey = GlobalKey(); late EditorTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; @override void initState() { super.initState(); _selectionGestureDetectorBuilder = _QuillEditorSelectionGestureDetectorBuilder(this); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final selectionTheme = TextSelectionTheme.of(context); TextSelectionControls textSelectionControls; bool paintCursorAboveText; bool cursorOpacityAnimates; Offset? cursorOffset; Color? cursorColor; Color selectionColor; Radius? cursorRadius; switch (theme.platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: textSelectionControls = materialTextSelectionControls; paintCursorAboveText = false; cursorOpacityAnimates = false; cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; selectionColor = selectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); break; case TargetPlatform.iOS: case TargetPlatform.macOS: 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.of(context).devicePixelRatio, 0); break; default: throw UnimplementedError(); } final child = RawEditor( key: _editorKey, controller: widget.controller, focusNode: widget.focusNode, scrollController: widget.scrollController, scrollable: widget.scrollable, scrollBottomInset: widget.scrollBottomInset, padding: widget.padding, readOnly: widget.readOnly, placeholder: widget.placeholder, onLaunchUrl: widget.onLaunchUrl, toolbarOptions: ToolbarOptions( copy: widget.enableInteractiveSelection, cut: widget.enableInteractiveSelection, paste: widget.enableInteractiveSelection, selectAll: widget.enableInteractiveSelection, ), showSelectionHandles: theme.platform == TargetPlatform.iOS || theme.platform == TargetPlatform.android, showCursor: widget.showCursor, cursorStyle: CursorStyle( color: cursorColor, backgroundColor: Colors.grey, width: 2, radius: cursorRadius, offset: cursorOffset, paintAboveText: widget.paintCursorAboveText ?? paintCursorAboveText, opacityAnimates: cursorOpacityAnimates, ), textCapitalization: widget.textCapitalization, minHeight: widget.minHeight, maxHeight: widget.maxHeight, customStyles: widget.customStyles, expands: widget.expands, autoFocus: widget.autoFocus, selectionColor: selectionColor, selectionCtrls: textSelectionControls, keyboardAppearance: widget.keyboardAppearance, enableInteractiveSelection: widget.enableInteractiveSelection, scrollPhysics: widget.scrollPhysics, embedBuilder: widget.embedBuilder, customStyleBuilder: widget.customStyleBuilder, ); return _selectionGestureDetectorBuilder.build( HitTestBehavior.translucent, child, ); } @override GlobalKey getEditableTextKey() { return _editorKey; } @override bool getForcePressEnabled() { return false; } @override bool getSelectionEnabled() { return widget.enableInteractiveSelection; } void _requestKeyboard() { _editorKey.currentState!.requestKeyboard(); } } class _QuillEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder { _QuillEditorSelectionGestureDetectorBuilder(this._state) : super(_state); final _QuillEditorState _state; @override void onForcePressStart(ForcePressDetails details) { super.onForcePressStart(details); if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) { getEditor()!.showToolbar(); } } @override void onForcePressEnd(ForcePressDetails details) {} @override void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { if (_state.widget.onSingleLongTapMoveUpdate != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { if (_state.widget.onSingleLongTapMoveUpdate!( details, renderEditor.getPositionForOffset)) { return; } } } if (!delegate.getSelectionEnabled()) { return; } switch (Theme.of(_state.context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: getRenderEditor()!.selectPositionAt( details.globalPosition, null, SelectionChangedCause.longPress, ); break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: getRenderEditor()!.selectWordsInRange( details.globalPosition - details.offsetFromOrigin, details.globalPosition, SelectionChangedCause.longPress, ); break; default: throw 'Invalid platform'; } } bool _onTapping(TapUpDetails details) { if (_state.widget.controller.document.isEmpty()) { return false; } final pos = getRenderEditor()!.getPositionForOffset(details.globalPosition); final result = getEditor()!.widget.controller.document.queryChild(pos.offset); if (result.node == null) { return false; } final line = result.node as Line; final segmentResult = line.queryChild(result.offset, false); if (segmentResult.node == null) { if (line.length == 1) { getEditor()!.widget.controller.updateSelection( TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); return true; } return false; } final segment = segmentResult.node as leaf.Leaf; if (segment.style.containsKey(Attribute.link.key)) { var launchUrl = getEditor()!.widget.onLaunchUrl; launchUrl ??= _launchUrl; String? link = segment.style.attributes[Attribute.link.key]!.value; if (getEditor()!.widget.readOnly && link != null) { link = link.trim(); if (!linkPrefixes .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { link = 'https://$link'; } launchUrl(link); } return false; } if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) { final blockEmbed = segment.value as BlockEmbed; if (blockEmbed.type == 'image') { final imageUrl = _standardizeImageUrl(blockEmbed.data); Navigator.push( getEditor()!.context, MaterialPageRoute( builder: (context) => ImageTapWrapper( imageProvider: imageUrl.startsWith('http') ? NetworkImage(imageUrl) : isBase64(imageUrl) ? Image.memory(base64.decode(imageUrl)) as ImageProvider? : FileImage(io.File(imageUrl)), ), ), ); } } return false; } Future _launchUrl(String url) async { await launch(url); } @override void onTapDown(TapDownDetails details) { if (_state.widget.onTapDown != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { if (_state.widget.onTapDown!( details, renderEditor.getPositionForOffset)) { return; } } } super.onTapDown(details); } @override void onSingleTapUp(TapUpDetails details) { if (_state.widget.onTapUp != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { if (_state.widget.onTapUp!( details, renderEditor.getPositionForOffset)) { return; } } } getEditor()!.hideToolbar(); final positionSelected = _onTapping(details); if (delegate.getSelectionEnabled() && !positionSelected) { switch (Theme.of(_state.context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: switch (details.kind) { case PointerDeviceKind.mouse: case PointerDeviceKind.stylus: case PointerDeviceKind.invertedStylus: getRenderEditor()!.selectPosition(SelectionChangedCause.tap); break; case PointerDeviceKind.touch: case PointerDeviceKind.unknown: getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); break; } break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: getRenderEditor()!.selectPosition(SelectionChangedCause.tap); break; } } _state._requestKeyboard(); } @override void onSingleLongTapStart(LongPressStartDetails details) { if (_state.widget.onSingleLongTapStart != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { if (_state.widget.onSingleLongTapStart!( details, renderEditor.getPositionForOffset)) { return; } } } if (delegate.getSelectionEnabled()) { switch (Theme.of(_state.context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: getRenderEditor()!.selectPositionAt( details.globalPosition, null, SelectionChangedCause.longPress, ); break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: getRenderEditor()!.selectWord(SelectionChangedCause.longPress); Feedback.forLongPress(_state.context); break; default: throw 'Invalid platform'; } } } @override void onSingleLongTapEnd(LongPressEndDetails details) { if (_state.widget.onSingleLongTapEnd != null) { final renderEditor = getRenderEditor(); if (renderEditor != null) { if (_state.widget.onSingleLongTapEnd!( details, renderEditor.getPositionForOffset)) { return; } } } super.onSingleLongTapEnd(details); } } typedef TextSelectionChangedHandler = void Function( TextSelection selection, SelectionChangedCause cause); class RenderEditor extends RenderEditableContainerBox implements RenderAbstractEditor { RenderEditor( ViewportOffset? offset, List? children, TextDirection textDirection, double scrollBottomInset, EdgeInsetsGeometry padding, this.document, this.selection, this._hasFocus, this.onSelectionChanged, this._startHandleLayerLink, this._endHandleLayerLink, EdgeInsets floatingCursorAddedMargin, ) : super( children, document.root, textDirection, scrollBottomInset, padding, ); Document document; TextSelection selection; bool _hasFocus = false; LayerLink _startHandleLayerLink; LayerLink _endHandleLayerLink; TextSelectionChangedHandler onSelectionChanged; final ValueNotifier _selectionStartInViewport = ValueNotifier(true); ValueListenable get selectionStartInViewport => _selectionStartInViewport; ValueListenable get selectionEndInViewport => _selectionEndInViewport; final ValueNotifier _selectionEndInViewport = ValueNotifier(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(); } 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(); } @override List getEndpointsForSelection( TextSelection textSelection) { if (textSelection.isCollapsed) { final child = childAtPosition(textSelection.extent); final localPosition = TextPosition( offset: textSelection.extentOffset - child.getContainer().offset); final localOffset = child.getOffsetForCaret(localPosition); final parentData = child.parentData as BoxParentData; return [ 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.getContainer() == baseNode) { break; } baseChild = childAfter(baseChild); } assert(baseChild != null); final baseParentData = baseChild!.parentData as BoxParentData; final baseSelection = localSelection(baseChild.getContainer(), 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.getContainer() == extentNode) { break; } extentChild = childAfter(extentChild); } assert(extentChild != null); final extentParentData = extentChild!.parentData as BoxParentData; final extentSelection = localSelection(extentChild.getContainer(), textSelection, true); var extentPoint = extentChild.getExtentEndpointForSelection(extentSelection); extentPoint = TextSelectionPoint( extentPoint.point + extentParentData.offset, extentPoint.direction); return [basePoint, extentPoint]; } Offset? _lastTapDownPosition; @override void handleTapDown(TapDownDetails details) { _lastTapDownPosition = details.globalPosition; } @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); } @override void selectWordEdge(SelectionChangedCause cause) { assert(_lastTapDownPosition != null); final position = getPositionForOffset(_lastTapDownPosition!); final child = childAtPosition(position); final nodeOffset = child.getContainer().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) { _handleSelectionChange( TextSelection.collapsed(offset: word.start), cause, ); } else { _handleSelectionChange( TextSelection.collapsed( offset: word.end, affinity: TextAffinity.upstream), cause, ); } } @override void selectPositionAt( Offset from, Offset? to, SelectionChangedCause cause, ) { 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, ); _handleSelectionChange(newSelection, cause); } @override void selectWord(SelectionChangedCause cause) { selectWordsInRange(_lastTapDownPosition!, null, cause); } @override void selectPosition(SelectionChangedCause cause) { selectPositionAt(_lastTapDownPosition!, null, cause); } @override TextSelection selectWordAtPosition(TextPosition position) { final child = childAtPosition(position); final nodeOffset = child.getContainer().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.end) { return TextSelection.fromPosition(position); } return TextSelection(baseOffset: word.start, extentOffset: word.end); } @override TextSelection selectLineAtPosition(TextPosition position) { final child = childAtPosition(position); final nodeOffset = child.getContainer().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, ); if (position.offset >= line.end) { return TextSelection.fromPosition(position); } return TextSelection(baseOffset: line.start, extentOffset: line.end); } @override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); _updateSelectionExtentsVisibility(offset + _paintOffset); _paintHandleLayers(context, getEndpointsForSelection(selection)); } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { return defaultHitTestChildren(result, position: position); } void _paintHandleLayers( PaintingContext context, List 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.getContainer().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.getContainer().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. double? getOffsetToRevealCursor( double viewportHeight, double scrollOffset, double offsetInViewport) { 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; } } final child = childAtPosition(selection.extent); const kMargin = 8.0; final caretTop = endpoint.point.dy - child.preferredLineHeight(TextPosition( offset: selection.extentOffset - child.getContainer().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; } 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)); } } class EditableContainerParentData extends ContainerBoxParentData {} class RenderEditableContainerBox extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { RenderEditableContainerBox( List? children, this._container, this.textDirection, this.scrollBottomInset, this._padding, ) : assert(_padding.isNonNegative) { addAll(children); } container_node.Container _container; TextDirection textDirection; EdgeInsetsGeometry _padding; double scrollBottomInset; EdgeInsets? _resolvedPadding; container_node.Container getContainer() { return _container; } void setContainer(container_node.Container 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.getContainer() == targetNode) { break; } final newChild = childAfter(targetChild); if (newChild == null) { break; } targetChild = newChild; } if (targetChild == null) { throw 'targetChild should not be null'; } return targetChild; } void _markNeedsPaddingResolution() { _resolvedPadding = null; markNeedsLayout(); } 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); } throw 'No child'; } @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( 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( 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( 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( 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; } }