From 484c9fcc49a1d6aec26974bf31ed6e181d67a3ae Mon Sep 17 00:00:00 2001 From: Alan Mantoux Date: Tue, 30 Apr 2024 08:28:15 +0200 Subject: [PATCH] Rebase and restore scrollable option --- .../fleather/lib/src/rendering/editor.dart | 121 ++++++++++------ packages/fleather/lib/src/widgets/editor.dart | 136 +++++++----------- .../lib/src/widgets/text_selection.dart | 26 ++-- ...editor_input_client_mixin_deltas_test.dart | 2 + 4 files changed, 146 insertions(+), 139 deletions(-) diff --git a/packages/fleather/lib/src/rendering/editor.dart b/packages/fleather/lib/src/rendering/editor.dart index 120b3401..63dd594a 100644 --- a/packages/fleather/lib/src/rendering/editor.dart +++ b/packages/fleather/lib/src/rendering/editor.dart @@ -137,21 +137,22 @@ class RenderEditor extends RenderEditableContainerBox with RelayoutWhenSystemFontsChangeMixin implements RenderAbstractEditor { RenderEditor({ - ViewportOffset? offset, super.children, - required ParchmentDocument document, + required super.padding, required super.textDirection, + required ParchmentDocument document, + required ViewportOffset offset, required bool hasFocus, required TextSelection selection, required LayerLink startHandleLayerLink, required LayerLink endHandleLayerLink, - required super.padding, required CursorController cursorController, this.onSelectionChanged, EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), double? maxContentWidth, }) : _document = document, + _offset = offset, _hasFocus = hasFocus, _selection = selection, _startHandleLayerLink = startHandleLayerLink, @@ -186,19 +187,26 @@ class RenderEditor extends RenderEditableContainerBox markNeedsSemanticsUpdate(); } - Offset get paintOffset => Offset(0.0, -(offset?.pixels ?? 0.0)); + Offset get paintOffset => Offset(0.0, -offset.pixels); - ViewportOffset? get offset => _offset; - ViewportOffset? _offset; + ViewportOffset get offset => _offset; + ViewportOffset _offset; - set offset(ViewportOffset? value) { + set offset(ViewportOffset value) { if (_offset == value) return; - if (attached) _offset?.removeListener(markNeedsPaint); + if (attached) _offset.removeListener(markNeedsPaint); _offset = value; - if (attached) _offset?.addListener(markNeedsPaint); + if (attached) _offset.addListener(markNeedsPaint); markNeedsLayout(); } + double _maxScrollExtent = 0; + + // We need to check the paint offset here because during animation, the start of + // the text may position outside the visible region even when the text fits. + bool get _hasVisualOverflow => + _maxScrollExtent > 0 || paintOffset != Offset.zero; + Offset? _lastSecondaryTapDownPosition; Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition; @@ -331,8 +339,7 @@ class RenderEditor extends RenderEditableContainerBox /// this editor from above it. /// /// Returns `null` if the cursor is currently visible. - double? getOffsetToRevealCursor( - double viewportHeight, double scrollOffset, double offsetInViewport) { + double? getOffsetToRevealCursor(double viewportHeight, double scrollOffset) { const kMargin = 8.0; // 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 @@ -346,10 +353,8 @@ class RenderEditor extends RenderEditableContainerBox offset: selection.extentOffset - child.node.documentOffset); final caretTop = endpoints.single.point.dy - child.preferredLineHeight(childPosition) - - kMargin + - offsetInViewport; - final caretBottom = - endpoints.single.point.dy + kMargin + offsetInViewport; + kMargin; + final caretBottom = endpoints.single.point.dy + kMargin; final caretHeight = caretBottom - caretTop; double? dy; @@ -572,20 +577,6 @@ class RenderEditor extends RenderEditableContainerBox @override void performLayout() { - assert(() { - if (!constraints.hasBoundedHeight) return true; - throw FlutterError.fromParts([ - ErrorSummary( - 'RenderEditableContainerBox must have unlimited space along its main axis.'), - 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.') - ]); - }()); assert(() { if (constraints.hasBoundedWidth) return true; throw FlutterError.fromParts([ @@ -601,7 +592,7 @@ class RenderEditor extends RenderEditableContainerBox resolvePadding(); assert(resolvedPadding != null); - var mainAxisExtent = resolvedPadding!.top; + var contentSize = resolvedPadding!.top; var child = firstChild; final innerConstraints = BoxConstraints.tightFor( width: math.min( @@ -614,17 +605,30 @@ class RenderEditor extends RenderEditableContainerBox child.layout(innerConstraints, parentUsesSize: true); final childParentData = child.parentData as EditableContainerParentData; childParentData.offset = - Offset(resolvedPadding!.left + leftOffset, mainAxisExtent); - mainAxisExtent += child.size.height; + Offset(resolvedPadding!.left + leftOffset, contentSize); + contentSize += child.size.height; assert(child.parentData == childParentData); child = childParentData.nextSibling; } - mainAxisExtent += resolvedPadding!.bottom; - size = constraints.constrain(Size(constraints.maxWidth, mainAxisExtent)); + contentSize += resolvedPadding!.bottom; + size = constraints + .constrain(Size(_maxContentWidth ?? constraints.maxWidth, contentSize)); + _maxScrollExtent = math.max(0.0, contentSize - size.height); + offset.applyViewportDimension(size.height); + offset.applyContentDimensions(0.0, _maxScrollExtent); assert(size.isFinite); } + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _offset.addListener(markNeedsPaint); + } + + final LayerHandle _clipRectLayer = + LayerHandle(); + @override void paint(PaintingContext context, Offset offset) { if (hasFocus && @@ -632,7 +636,19 @@ class RenderEditor extends RenderEditableContainerBox !_cursorController.style.paintAboveText) { _paintFloatingCursor(context, offset); } - defaultPaint(context, offset); + if (_hasVisualOverflow) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (context, offset) => defaultPaint(context, offset + paintOffset), + clipBehavior: Clip.hardEdge, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + defaultPaint(context, offset); + } _updateSelectionExtentsVisibility(offset + paintOffset); _paintHandleLayers(context, getEndpointsForSelection(selection)); @@ -650,7 +666,7 @@ class RenderEditor extends RenderEditableContainerBox void _paintHandleLayers( PaintingContext context, List endpoints) { - var startPoint = endpoints[0].point; + var startPoint = endpoints[0].point + paintOffset; startPoint = Offset( startPoint.dx.clamp(0.0, size.width), startPoint.dy.clamp(0.0, size.height), @@ -661,7 +677,7 @@ class RenderEditor extends RenderEditableContainerBox Offset.zero, ); if (endpoints.length == 2) { - var endPoint = endpoints[1].point; + var endPoint = endpoints[1].point + paintOffset; endPoint = Offset( endPoint.dx.clamp(0.0, size.width), endPoint.dy.clamp(0.0, size.height), @@ -686,10 +702,10 @@ class RenderEditor extends RenderEditableContainerBox @override TextPosition getPositionForOffset(Offset offset) { final local = globalToLocal(offset); - final child = childAtOffset(local); + final child = childAtOffset(local - paintOffset); final parentData = child.parentData as BoxParentData; - final localOffset = local - parentData.offset; + final localOffset = local - parentData.offset - paintOffset; final localPosition = child.getPositionForOffset(localOffset); return TextPosition( offset: localPosition.offset + child.node.offset, @@ -697,6 +713,30 @@ class RenderEditor extends RenderEditableContainerBox ); } + // Override needed to account for ViewPort-like behaviour of renderer + @override + RenderEditableBox childAtOffset(Offset offset) { + assert(firstChild != null); + resolvePadding(); + + if (offset.dy <= resolvedPadding!.top) return firstChild!; + if (offset.dy >= _maxScrollExtent - resolvedPadding!.bottom) { + return lastChild!; + } + + var child = firstChild; + var dy = resolvedPadding!.top; + var dx = -offset.dx; + while (child != null) { + if (child.size.contains(offset.translate(dx, -dy))) { + return child; + } + dy += child.size.height; + child = childAfter(child); + } + throw StateError('No child at offset $offset.'); + } + @override Rect getLocalRectForCaret(TextPosition position) { final targetChild = childAtPosition(position); @@ -705,7 +745,8 @@ class RenderEditor extends RenderEditableContainerBox final childLocalRect = targetChild.getLocalRectForCaret(localPosition); final boxParentData = targetChild.parentData as BoxParentData; - return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); + return childLocalRect + .shift(Offset(0, boxParentData.offset.dy + paintOffset.dy)); } // Start floating cursor diff --git a/packages/fleather/lib/src/widgets/editor.dart b/packages/fleather/lib/src/widgets/editor.dart index c34cb2c1..0b20a548 100644 --- a/packages/fleather/lib/src/widgets/editor.dart +++ b/packages/fleather/lib/src/widgets/editor.dart @@ -13,8 +13,8 @@ import 'package:parchment_delta/parchment_delta.dart'; import '../../util.dart'; import '../rendering/editor.dart'; -import '../services/clipboard_manager.dart'; import '../services/spell_check_suggestions_toolbar.dart'; +import '../services/clipboard_manager.dart'; import 'baseline_proxy.dart'; import 'controller.dart'; import 'cursor.dart'; @@ -26,7 +26,6 @@ import 'history.dart'; import 'keyboard_listener.dart'; import 'link.dart'; import 'shortcuts.dart'; -import 'single_child_scroll_view.dart'; import 'text_line.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -1555,15 +1554,9 @@ class RawEditorState extends EditorState return; } - final viewport = RenderAbstractViewport.of(renderEditor); - final editorOffset = renderEditor.localToGlobal(const Offset(0.0, 0.0), - ancestor: viewport); - final offsetInViewport = _scrollController.offset + editorOffset.dy; - final offset = renderEditor.getOffsetToRevealCursor( _scrollController.position.viewportDimension, - _scrollController.offset, - offsetInViewport, + _scrollController.offset ); if (offset != null) { @@ -1642,66 +1635,43 @@ class RawEditorState extends EditorState Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); super.build(context); // See AutomaticKeepAliveClientMixin. - - Widget child = CompositedTransformTarget( - link: _toolbarLayerLink, - child: Semantics( - child: _Editor( - key: _editorKey, - document: widget.controller.document, - selection: widget.controller.selection, - hasFocus: _hasFocus, - cursorController: _cursorController, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - children: _buildChildren(context), + final baselinePadding = + EdgeInsets.only(top: _themeData.paragraph.spacing.top); + final child = BaselineProxy( + textStyle: _themeData.paragraph.style, + padding: baselinePadding, + child: Scrollable( + key: _scrollableKey, + excludeFromSemantics: true, + controller: _scrollController, + axisDirection: AxisDirection.down, + scrollBehavior: ScrollConfiguration.of(context).copyWith( + scrollbars: true, + overscroll: false, ), - ), - ); - - if (widget.scrollable) { - // Since `SingleChildScrollView` does not implement - // `computeDistanceToActualBaseline` it prevents the editor from - // providing its baseline metrics. To address this issue we wrap - // the scroll view with [BaselineProxy] which mimics the editor's - // baseline. - // This implies that the first line has no styles applied to it. - final baselinePadding = - EdgeInsets.only(top: _themeData.paragraph.spacing.top); - child = BaselineProxy( - textStyle: _themeData.paragraph.style, - padding: baselinePadding, - child: FleatherSingleChildScrollView( - scrollableKey: _scrollableKey, - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => CompositedTransformTarget( - link: _toolbarLayerLink, - child: _Editor( - key: _editorKey, - offset: offset, - document: widget.controller.document, - selection: widget.controller.selection, - hasFocus: _hasFocus, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - cursorController: _cursorController, - children: _buildChildren(context), - ), + physics: widget.scrollPhysics, + viewportBuilder: (context, offset) => CompositedTransformTarget( + link: _toolbarLayerLink, + child: _Editor( + key: _editorKey, + offset: offset, + document: widget.controller.document, + selection: widget.controller.selection, + hasFocus: _hasFocus, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + cursorController: _cursorController, + children: _buildChildren(context), ), ), - ); - } + ), + ); - final constraints = widget.expands + final constraints = widget.expands || widget.scrollable ? const BoxConstraints.expand() : BoxConstraints( minHeight: widget.minHeight ?? 0.0, @@ -2037,12 +2007,10 @@ class RawEditorState extends EditorState }) { // If editor is scrollable, the editing region is only the viewport // otherwise use editor as editing region - final viewport = RenderAbstractViewport.maybeOf(renderEditor); - final visualSizeRenderer = (viewport ?? renderEditor) as RenderBox; + final paintOffset = renderEditor.paintOffset; final Rect editingRegion = Rect.fromPoints( - visualSizeRenderer.localToGlobal(Offset.zero), - visualSizeRenderer - .localToGlobal(visualSizeRenderer.size.bottomRight(Offset.zero)), + renderEditor.localToGlobal(Offset.zero), + renderEditor.localToGlobal(renderEditor.size.bottomRight(Offset.zero)), ); if (editingRegion.left.isNaN || @@ -2051,29 +2019,23 @@ class RawEditorState extends EditorState editingRegion.bottom.isNaN) { return const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero); } - + final viewportAdjustedBasePointDy = + selectionEndpoints.first.point.dy + paintOffset.dy; + final viewportAdjustedEndPointDy = + selectionEndpoints.last.point.dy + paintOffset.dy; final bool isMultiline = - selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy > + viewportAdjustedEndPointDy - viewportAdjustedBasePointDy > endGlyphHeight / 2; final Rect selectionRect = Rect.fromLTRB( isMultiline ? editingRegion.left - : editingRegion.left + - selectionEndpoints.first.point.dx + - renderEditor.paintOffset.dx, - editingRegion.top + - selectionEndpoints.first.point.dy + - renderEditor.paintOffset.dy - - startGlyphHeight, + : editingRegion.left + selectionEndpoints.first.point.dx, + editingRegion.top + viewportAdjustedBasePointDy - startGlyphHeight, isMultiline ? editingRegion.right - : editingRegion.left + - selectionEndpoints.last.point.dx + - renderEditor.paintOffset.dx, - editingRegion.top + - selectionEndpoints.last.point.dy + - renderEditor.paintOffset.dy, + : editingRegion.left + selectionEndpoints.last.point.dx, + editingRegion.top + viewportAdjustedEndPointDy, ); return TextSelectionToolbarAnchors( @@ -2121,7 +2083,7 @@ class _Editor extends MultiChildRenderObjectWidget { const _Editor({ required Key super.key, required super.children, - this.offset, + required this.offset, required this.document, required this.textDirection, required this.hasFocus, @@ -2134,7 +2096,7 @@ class _Editor extends MultiChildRenderObjectWidget { this.maxContentWidth, }); - final ViewportOffset? offset; + final ViewportOffset offset; final ParchmentDocument document; final TextDirection textDirection; final bool hasFocus; diff --git a/packages/fleather/lib/src/widgets/text_selection.dart b/packages/fleather/lib/src/widgets/text_selection.dart index 64aa75af..5313a83e 100644 --- a/packages/fleather/lib/src/widgets/text_selection.dart +++ b/packages/fleather/lib/src/widgets/text_selection.dart @@ -272,7 +272,11 @@ class EditorTextSelectionOverlay { ? selectionDelegate.textEditingValue.selection.base : selectionDelegate.textEditingValue.selection.extent) // Update selection toolbar metrics. - ..selectionEndpoints = renderObject.getEndpointsForSelection(_selection) + ..selectionEndpoints = renderObject + .getEndpointsForSelection(_selection) + .map((e) => TextSelectionPoint( + e.point + renderObject.paintOffset, e.direction)) + .toList() ..toolbarLocation = renderObject.lastSecondaryTapDownPosition; } @@ -507,7 +511,7 @@ class EditorTextSelectionOverlay { // perfectly cover the TextPosition that they correspond to. _startHandleDragPosition = details.globalPosition.dy; final Offset startPoint = renderObject - .localToGlobal(_selectionOverlay.selectionEndpoints.first.point); + .localToGlobal(_selectionOverlay.selectionEndpoints.first.point); final double centerOfLine = startPoint.dy - renderObject.preferredLineHeight( selectionDelegate.textEditingValue.selection.extent) / @@ -1103,8 +1107,7 @@ class SelectionOverlay { contextMenuBuilder: (BuildContext context) { return _SelectionToolbarWrapper( layerLink: toolbarLayerLink, - offset: -renderBox.localToGlobal(Offset.zero) + - Offset(0, renderEditor.offset?.pixels ?? 0.0), + offset: -renderBox.localToGlobal(Offset.zero), child: contextMenuBuilder(context), ); }, @@ -1127,8 +1130,7 @@ class SelectionOverlay { contextMenuBuilder: (BuildContext context) { return _SelectionToolbarWrapper( layerLink: toolbarLayerLink, - offset: -renderBox.localToGlobal(Offset.zero) + - Offset(0, renderEditor.offset?.pixels ?? 0.0), + offset: -renderBox.localToGlobal(Offset.zero), child: builder(context), ); }, @@ -2062,7 +2064,7 @@ class EditorTextSelectionGestureDetectorBuilder { _showMagnifierIfSupportedByPlatform(details.globalPosition); - _dragStartViewportOffset = renderEditor.offset?.pixels ?? 0; + _dragStartViewportOffset = renderEditor.paintOffset.dy; _dragStartScrollOffset = _scrollPosition; } } @@ -2080,8 +2082,8 @@ class EditorTextSelectionGestureDetectorBuilder { void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { if (delegate.selectionEnabled) { // Adjust the drag start offset for possible viewport offset changes. - final Offset editableOffset = Offset( - 0.0, (renderEditor.offset?.pixels ?? 0) - _dragStartViewportOffset); + final Offset editableOffset = + Offset(0.0, renderEditor.paintOffset.dy - _dragStartViewportOffset); final Offset scrollableOffset = Offset( 0.0, _scrollPosition - _dragStartScrollOffset, @@ -2334,7 +2336,7 @@ class EditorTextSelectionGestureDetectorBuilder { _dragStartSelection = renderEditor.selection; _dragStartScrollOffset = _scrollPosition; - _dragStartViewportOffset = renderEditor.offset?.pixels ?? 0; + _dragStartViewportOffset = renderEditor.paintOffset.dy; _dragBeganOnPreviousSelection = _positionOnSelection(details.globalPosition, _dragStartSelection); @@ -2432,8 +2434,8 @@ class EditorTextSelectionGestureDetectorBuilder { if (!_isShiftPressed) { // Adjust the drag start offset for possible viewport offset changes. - final Offset editableOffset = Offset( - 0.0, (renderEditor.offset?.pixels ?? 0) - _dragStartViewportOffset); + final Offset editableOffset = + Offset(0.0, renderEditor.paintOffset.dy - _dragStartViewportOffset); final Offset scrollableOffset = Offset( 0.0, _scrollPosition - _dragStartScrollOffset, diff --git a/packages/fleather/test/widgets/editor_input_client_mixin_deltas_test.dart b/packages/fleather/test/widgets/editor_input_client_mixin_deltas_test.dart index 35ade2cf..2c1f46a6 100644 --- a/packages/fleather/test/widgets/editor_input_client_mixin_deltas_test.dart +++ b/packages/fleather/test/widgets/editor_input_client_mixin_deltas_test.dart @@ -1,6 +1,7 @@ import 'package:fleather/fleather.dart'; import 'package:fleather/src/widgets/editor_input_client_mixin.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,6 +17,7 @@ class MockRawEditor extends Mock implements RawEditor { class MockEditorState extends Mock implements EditorState { @override RenderEditor renderEditor = RenderEditor( + offset: ViewportOffset.zero(), document: ParchmentDocument.fromJson([ {'insert': 'Some text\n'} ]),