import 'dart:math' as math; import 'package:flowy_editor/widget/selection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import '../model/document/node/container.dart' as node; import '../model/document/document.dart'; import 'box.dart'; typedef TextSelectionChangeHandler = void Function( TextSelection selection, SelectionChangedCause cause, ); /* ----------------------------- Abstract Editor ---------------------------- */ abstract class RenderAbstractEditor { TextSelection selectWordAtPosition(TextPosition position); TextSelection selectLineAtPosition(TextPosition position); double preferredLineHeight(TextPosition position); TextPosition getPositionForOffset(Offset offset); List getEndpointsForSelection( TextSelection textSelection); void handleTapDown(TapDownDetails details); void selectWordsInRange(Offset from, Offset to, SelectionChangedCause cause); void selectWordEdge(SelectionChangedCause cause); void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); void selectWord(SelectionChangedCause cause); void selectPosition(SelectionChangedCause cause); } /* ------------------------------ Container Box ----------------------------- */ class EditableContainerParentData extends ContainerBoxParentData {} class RenderEditableContainerBox extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { RenderEditableContainerBox( List? children, this.textDirection, this.scrollBottomInset, this._container, this._padding, ) : assert(_padding.isNonNegative) { addAll(children); } TextDirection textDirection; double scrollBottomInset; node.Container _container; EdgeInsetsGeometry _padding; EdgeInsets? _resolvedPadding; node.Container get container => _container; set container(node.Container container) { if (_container == container) { return; } _container = container; markNeedsLayout(); } EdgeInsetsGeometry get padding => _padding; set padding(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); } void _markNeedsPaddingResolution() { _resolvedPadding = null; markNeedsLayout(); } 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; } targetChild = childAfter(targetChild); } if (targetChild == null) { throw '`targetChild` should not be null'; } return targetChild; } 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(covariant RenderBox child) { if (child.parent is EditableContainerParentData) { return; } child.parentData = EditableContainerParentData(); } @override void performLayout() { assert(constraints.hasBoundedWidth); assert(!constraints.hasBoundedHeight); _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; } } /* ------------------------------ Render Editor ----------------------------- */ class RenderEditor extends RenderEditableContainerBox implements RenderAbstractEditor { RenderEditor( List? children, TextDirection textDirection, double scrollBottomInset, EdgeInsetsGeometry padding, EdgeInsets floatingCursorAddedMargin, this._document, this._selection, this._hasFocus, this.onSelectionChanged, this._startHandleLayerLink, this._endHandleLayerLink, ) : super( children, textDirection, scrollBottomInset, _document.root, padding, ); TextSelectionChangeHandler onSelectionChanged; Document _document; TextSelection _selection; final ValueNotifier _selectionStartInViewport = ValueNotifier(true); final ValueNotifier _selectionEndInViewport = ValueNotifier(true); bool _hasFocus = false; LayerLink _startHandleLayerLink; LayerLink _endHandleLayerLink; Offset? _lastTapDownPosition; Document get document => _document; ValueListenable get selectionStartInViewport => _selectionStartInViewport; ValueListenable get selectionEndInViewport => _selectionEndInViewport; set document(Document value) { if (_document == value) { return; } _document = value; markNeedsLayout(); } set hasFocus(bool value) { if (_hasFocus == value) { return; } _hasFocus = value; markNeedsSemanticsUpdate(); } set selection(TextSelection value) { if (_selection == value) { return; } _selection = value; markNeedsPaint(); } set startHandleLayerLink(LayerLink value) { if (_startHandleLayerLink == value) { return; } _startHandleLayerLink = value; markNeedsPaint(); } set endHandleLayerLink(LayerLink value) { if (_endHandleLayerLink == value) { return; } _endHandleLayerLink = value; markNeedsPaint(); } @override set scrollBottomInset(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.container.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.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 [basePoint, extentPoint]; } @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, ); } @override void handleTapDown(TapDownDetails details) { _lastTapDownPosition = details.globalPosition; } @override double preferredLineHeight(TextPosition position) { final child = childAtPosition(position); return child.preferredLineHeight( TextPosition(offset: position.offset - child.container.offset), ); } @override TextSelection selectLineAtPosition(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, ); if (position.offset >= line.end) { return TextSelection.fromPosition(position); } return TextSelection(baseOffset: line.start, extentOffset: line.end); } @override void selectPosition(SelectionChangedCause cause) { selectPositionAt(_lastTapDownPosition!, null, 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 TextSelection selectWordAtPosition(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); 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 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) { _handleSelectionChange( TextSelection.collapsed(offset: word.start), cause); } else { _handleSelectionChange( TextSelection.collapsed( offset: word.end, affinity: TextAffinity.upstream), cause, ); } } @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); } @override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); _paintHandleLayers(context, getEndpointsForSelection(_selection)); } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { return defaultHitTestChildren(result, position: position); } /// 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); final endpoint = endpoints.first; final child = childAtPosition(_selection.extent); const kMargin = 8.0; final lineHeight = child.preferredLineHeight( TextPosition(offset: _selection.extentOffset - child.container.offset), ); final caretTop = endpoint.point.dy - lineHeight - 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); } // Util 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); } 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, ); } } }