diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 4dfdb6b66f..32b7447a8b 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -149,13 +149,10 @@ class _ExampleEditorState extends State { // text. // TODO: switch this to use a Leader and Follower WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout) - .getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; - final docBox = _docLayoutKey.currentContext!.findRenderObject() as RenderBox; - final overlayBoundingBox = Rect.fromPoints( - docBox.localToGlobal(docBoundingBox.topLeft), - docBox.localToGlobal(docBoundingBox.bottomRight), - ); + final layout = _docLayoutKey.currentState as DocumentLayout; + final docBoundingBox = layout.getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; + final globalOffset = layout.getGlobalOffsetFromDocumentOffset(Offset.zero); + final overlayBoundingBox = docBoundingBox.shift(globalOffset); _textSelectionAnchor.value = overlayBoundingBox.topCenter; }); diff --git a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart index fd45a5931b..fe61712660 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart @@ -106,49 +106,48 @@ class _ActionTagsFeatureDemoState extends State { } Widget _buildEditor() { - return IntrinsicHeight( - child: SuperEditor( - editor: _editor, - focusNode: _editorFocusNode, - componentBuilders: [ - TaskComponentBuilder(_editor), - ...defaultComponentBuilders, - ], - stylesheet: defaultStylesheet.copyWith( - inlineTextStyler: (attributions, existingStyle) { - TextStyle style = defaultInlineTextStyler(attributions, existingStyle); - - if (attributions.contains(actionTagComposingAttribution)) { - style = style.copyWith( - color: Colors.blue, - ); - } - - return style; - }, - addRulesAfter: [ - ...darkModeStyles, - ], - ), - documentOverlayBuilders: [ - AttributedTextBoundsOverlay( - selector: (a) => a == actionTagComposingAttribution, - builder: (BuildContext context, Attribution attribution) { - return Leader( - link: _composingLink, - child: const SizedBox(), - ); - }, - ), - DefaultCaretOverlayBuilder( - caretStyle: CaretStyle().copyWith(color: Colors.redAccent), - ), - ], - plugins: { - _actionTagPlugin, + return SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + componentBuilders: [ + TaskComponentBuilder(_editor), + ...defaultComponentBuilders, + ], + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(actionTagComposingAttribution)) { + style = style.copyWith( + color: Colors.blue, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ], + ), + documentOverlayBuilders: [ + AttributedTextBoundsOverlay( + selector: (a) => a == actionTagComposingAttribution, + builder: (BuildContext context, Attribution attribution) { + return Leader( + link: _composingLink, + child: const SizedBox(), + ); }, ), - ); + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _actionTagPlugin, + }, + ); } Widget _buildTagList() { diff --git a/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart index 2b040bf2d3..120121381e 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart @@ -61,35 +61,34 @@ class _HashTagsFeatureDemoState extends State { } Widget _buildEditor() { - return IntrinsicHeight( - child: SuperEditor( - editor: _editor, - stylesheet: defaultStylesheet.copyWith( - inlineTextStyler: (attributions, existingStyle) { - TextStyle style = defaultInlineTextStyler(attributions, existingStyle); - - if (attributions.whereType().isNotEmpty) { - style = style.copyWith( - color: Colors.orange, - ); - } - - return style; - }, - addRulesAfter: [ - ...darkModeStyles, - ], - ), - documentOverlayBuilders: [ - DefaultCaretOverlayBuilder( - caretStyle: CaretStyle().copyWith(color: Colors.redAccent), - ), - ], - plugins: { - _hashTagPlugin, - }, + return SuperEditor( + editor: _editor, + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ], + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), ), - ); + ], + plugins: { + _hashTagPlugin, + }, + ); } Widget _buildTagList() { diff --git a/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart index 7cc0887c70..7428da09c3 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart @@ -107,51 +107,50 @@ class _UserTagsFeatureDemoState extends State { } Widget _buildEditor() { - return IntrinsicHeight( - child: SuperEditor( - editor: _editor, - focusNode: _editorFocusNode, - stylesheet: defaultStylesheet.copyWith( - inlineTextStyler: (attributions, existingStyle) { - TextStyle style = defaultInlineTextStyler(attributions, existingStyle); - - if (attributions.contains(stableTagComposingAttribution)) { - style = style.copyWith( - color: Colors.blue, - ); - } - - if (attributions.whereType().isNotEmpty) { - style = style.copyWith( - color: Colors.orange, - ); - } - - return style; - }, - addRulesAfter: [ - ...darkModeStyles, - ], - ), - documentOverlayBuilders: [ - AttributedTextBoundsOverlay( - selector: (a) => a == stableTagComposingAttribution, - builder: (context, attribution) { - return Leader( - link: _composingLink, - child: const SizedBox(), - ); - }, - ), - DefaultCaretOverlayBuilder( - caretStyle: CaretStyle().copyWith(color: Colors.redAccent), - ), - ], - plugins: { - _userTagPlugin, + return SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(stableTagComposingAttribution)) { + style = style.copyWith( + color: Colors.blue, + ); + } + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ], + ), + documentOverlayBuilders: [ + AttributedTextBoundsOverlay( + selector: (a) => a == stableTagComposingAttribution, + builder: (context, attribution) { + return Leader( + link: _composingLink, + child: const SizedBox(), + ); }, ), - ); + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _userTagPlugin, + }, + ); } Widget _buildTagList() { diff --git a/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart b/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart index a105a92afe..0643d5a18a 100644 --- a/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart +++ b/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart @@ -60,9 +60,7 @@ class _SelectedTextColorsDemoState extends State { Widget build(BuildContext context) { return InTheLabScaffold( content: Center( - child: IntrinsicHeight( - child: _buildEditor(), - ), + child: _buildEditor(), ), supplemental: _buildControlPanel(), overlay: _buildOverlay(), @@ -72,6 +70,7 @@ class _SelectedTextColorsDemoState extends State { Widget _buildEditor() { return SuperEditor( editor: _editor, + shrinkWrap: true, stylesheet: defaultStylesheet.copyWith( selectedTextColorStrategy: _selectedTextColorStrategy, addRulesAfter: [ diff --git a/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart index 26cf399706..a2f8024ba2 100644 --- a/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart +++ b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart @@ -40,49 +40,52 @@ class _ToolbarFollowingContentInLayerState extends State LeaderLayoutLayer( - leaderLink: _leaderLink, - leaderBoundsKey: _leaderBoundsKey, - ), - ], - content: (_) => Center( - child: Column( - children: [ - const Spacer(), - ValueListenableBuilder( - valueListenable: _expansionExtent, - builder: (context, expansionExtent, _) { - return Container( - height: 12, - width: _baseContentWidth + (2 * expansionExtent) + 2, // +2 for border - decoration: BoxDecoration( - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - key: _leaderBoundsKey, - width: _baseContentWidth + expansionExtent, - height: 10, - color: Colors.white.withOpacity(0.2), - ), - ), - ); - }, - ), - const SizedBox(height: 96), - TextButton( - onPressed: () { - _expansionExtent.value = Random().nextDouble() * 200; - }, - child: Text("Change Size"), - ), - const Spacer(), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + ContentLayers( + overlays: [ + (_) => LeaderLayoutLayer( + leaderLink: _leaderLink, + leaderBoundsKey: _leaderBoundsKey, + ), ], + content: (_) => SliverToBoxAdapter( + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: _expansionExtent, + builder: (context, expansionExtent, _) { + return Container( + height: 12, + width: _baseContentWidth + (2 * expansionExtent) + 2, // +2 for border + decoration: BoxDecoration( + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + key: _leaderBoundsKey, + width: _baseContentWidth + expansionExtent, + height: 10, + color: Colors.white.withOpacity(0.2), + ), + ), + ); + }, + ), + const SizedBox(height: 96), + TextButton( + onPressed: () { + _expansionExtent.value = Random().nextDouble() * 200; + }, + child: Text("Change Size"), + ), + ], + ), + ), ), - ), + ], ), ), ), @@ -158,18 +161,22 @@ class LeaderLayoutLayerState extends ContentLayerState return const SizedBox(); } - return Center( - child: SizedBox( - width: layoutData.size.width * 2, - height: layoutData.size.height, - child: Align( - alignment: Alignment.centerLeft, - child: Leader( - link: widget.leaderLink, - child: SizedBox.fromSize( - size: layoutData.size, - child: ColoredBox( - color: Colors.red, + return Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: SizedBox( + width: layoutData.size.width * 2, + height: layoutData.size.height, + child: Align( + alignment: Alignment.centerLeft, + child: Leader( + link: widget.leaderLink, + child: SizedBox.fromSize( + size: layoutData.size, + child: ColoredBox( + color: Colors.red, + ), ), ), ), diff --git a/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart b/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart index c34b8cad1d..62491856fa 100644 --- a/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart +++ b/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart @@ -62,12 +62,10 @@ class _TaskAndChatWithCustomScrollViewDemoState extends State { textAlign: TextAlign.center, ), ), - SliverToBoxAdapter( - child: SuperEditor( - editor: _docEditor, - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), - ), - debugPaint: const DebugPaintConfig( - gestures: _showDebugPaint, - scrollingMinimapId: _showDebugPaint ? "sliver_demo" : null, - ), + SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + debugPaint: const DebugPaintConfig( + gestures: _showDebugPaint, + scrollingMinimapId: _showDebugPaint ? "sliver_demo" : null, ), ), SliverList( diff --git a/super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart b/super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart index 5e1be24daf..f54176c730 100644 --- a/super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart +++ b/super_editor/example/lib/demos/super_reader/demo_read_only_scrolling_document.dart @@ -33,9 +33,7 @@ class _ReadOnlyCustomScrollViewDemoState extends State - (context.findAncestorScrollableWithVerticalScroll?.context.findRenderObject() ?? context.findRenderObject()) - as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); @@ -1663,10 +1662,15 @@ class SuperEditorAndroidControlsOverlayManagerState extends State /// If this widget doesn't have an ancestor `Scrollable`, then this /// widget includes a `ScrollView` and this `State`'s render object /// is the viewport `RenderBox`. - RenderBox get viewportBox => - (context.findAncestorScrollableWithVerticalScroll?.context.findRenderObject() ?? context.findRenderObject()) - as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); Offset _documentOffsetToViewportOffset(Offset documentOffset) { final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); @@ -1460,7 +1460,7 @@ class SuperEditorIosToolbarOverlayManager extends StatefulWidget { super.key, this.tapRegionGroupId, this.defaultToolbarBuilder, - this.child, + required this.child, }); /// {@macro super_editor_tap_region_group_id} @@ -1468,7 +1468,7 @@ class SuperEditorIosToolbarOverlayManager extends StatefulWidget { final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; - final Widget? child; + final Widget child; @override State createState() => SuperEditorIosToolbarOverlayManagerState(); @@ -1492,10 +1492,15 @@ class SuperEditorIosToolbarOverlayManagerState extends State createState() => SuperEditorIosMagnifierOverlayManagerState(); @@ -1546,10 +1551,15 @@ class SuperEditorIosMagnifierOverlayManagerState extends State { /// /// This widget expects to wrap the viewport, so this widget's box is the same /// place and size as the actual viewport. - RenderBox get viewportBox => context.findRenderObject() as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); Offset _documentOffsetToViewportOffset(Offset documentOffset) { final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); @@ -1745,7 +1755,7 @@ class _EditorFloatingCursorState extends State { final cursorViewportFocalPointUnbounded = _initialFloatingCursorOffsetInViewport! + offset; editorIosFloatingCursorLog.finer(" - unbounded cursor focal point: $cursorViewportFocalPointUnbounded"); - final viewportHeight = (context.findRenderObject() as RenderBox).size.height; + final viewportHeight = viewportBox.size.height; _floatingCursorFocalPointInViewport = Offset(cursorViewportFocalPointUnbounded.dx, cursorViewportFocalPointUnbounded.dy.clamp(0, viewportHeight)); editorIosFloatingCursorLog.finer(" - bounded cursor focal point: $_floatingCursorFocalPointInViewport"); @@ -1843,10 +1853,14 @@ class _EditorFloatingCursorState extends State { @override Widget build(BuildContext context) { - return Stack( + return SliverHybridStack( children: [ widget.child, - _buildFloatingCursor(), + Stack( + children: [ + _buildFloatingCursor(), + ], + ) ], ); } diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart index 13938c303d..847d9736ef 100644 --- a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart @@ -103,6 +103,9 @@ class _SuperEditorHardwareKeyHandlerState extends State impl return; } - final myRenderBox = context.findRenderObject() as RenderBox?; - if (myRenderBox != null && myRenderBox.hasSize) { + final myRenderSliver = context.findRenderObject() as RenderSliver?; + if (myRenderSliver != null && myRenderSliver.hasSize) { _reportSizeAndTransformToIme(); _reportCaretRectToIme(); _reportTextStyleToIme(); @@ -326,10 +328,10 @@ class SuperEditorImeInteractorState extends State impl (size, transform) = sizeAndTransform; } else { - final renderBox = context.findRenderObject() as RenderBox; + final renderSliver = context.findRenderObject() as RenderSliver; - size = renderBox.size; - transform = renderBox.getTransformTo(null); + size = renderSliver.size; + transform = renderSliver.getTransformTo(null); } _imeConnection.value!.setEditableSizeAndTransform(size, transform); @@ -421,12 +423,12 @@ class SuperEditorImeInteractorState extends State impl return null; } - final renderBox = context.findRenderObject() as RenderBox; + final renderSliver = context.findRenderObject() as RenderSliver; // The value returned from getRectForPosition is in the document's layout coordinates. // As the document layout is scrollable, this rect might be outside of the viewport height. // Map the offset to the editor's viewport coordinates. - final caretOffset = renderBox.globalToLocal( + final caretOffset = renderSliver.globalToLocal( docLayout.getGlobalOffsetFromDocumentOffset(rectInDocLayoutSpace.topLeft), ); diff --git a/super_editor/lib/src/default_editor/document_scrollable.dart b/super_editor/lib/src/default_editor/document_scrollable.dart index 2b13d36eb0..2e635b3bc9 100644 --- a/super_editor/lib/src/default_editor/document_scrollable.dart +++ b/super_editor/lib/src/default_editor/document_scrollable.dart @@ -33,6 +33,7 @@ class DocumentScrollable extends StatefulWidget { this.scroller, this.scrollingMinimapId, this.showDebugPaint = false, + required this.shrinkWrap, required this.child, }) : super(key: key); @@ -62,6 +63,10 @@ class DocumentScrollable extends StatefulWidget { /// This widget's child, which should include a document. final Widget child; + /// Whether to shrink wrap the [CustomScrollView] that's used to host + /// the editor content. Only used when there's no ancestor [Scrollable]. + final bool shrinkWrap; + @override State createState() => _DocumentScrollableState(); } @@ -180,12 +185,14 @@ class _DocumentScrollableState extends State with SingleTick Widget build(BuildContext context) { final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; _ancestorScrollPosition = ancestorScrollable?.position; - + if (ancestorScrollable != null) { + return widget.child; + } return Stack( children: [ - _ancestorScrollPosition == null // - ? _buildScroller(child: widget.child) // - : widget.child, + _buildScroller( + child: widget.child, + ), if (widget.showDebugPaint) ..._buildScrollingDebugPaint( includesScrollView: ancestorScrollable == null, @@ -202,10 +209,11 @@ class _DocumentScrollableState extends State with SingleTick behavior: scrollBehavior, child: ScrollConfiguration( behavior: scrollBehavior.copyWith(scrollbars: false), - child: SingleChildScrollView( + child: CustomScrollView( controller: _scrollController, + shrinkWrap: widget.shrinkWrap, physics: const NeverScrollableScrollPhysics(), - child: child, + slivers: [child], ), ), ); @@ -378,7 +386,14 @@ class AutoScrollController with ChangeNotifier { // The scroll position changed. Probably because the position scrolled // up or down. Notify our listeners so that they can adjust the document // selection bounds or other related properties. - notifyListeners(); + // + // The scroll position may trigger layout changes, notify the listeners + // after the layout settles. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasScrollable) { + notifyListeners(); + } + }); } /// Stops controlling a [Scrollable] that was attached with [attachScrollable]. diff --git a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index 3a7d5eb2bd..ec7bfe18ec 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart @@ -73,6 +73,10 @@ class _SingleColumnDocumentLayoutState extends State late SingleColumnLayoutPresenterChangeListener _presenterListener; + // The key for the renderBox that contains the actual document layout. + final GlobalKey _boxKey = GlobalKey(); + BuildContext get boxContext => _boxKey.currentContext!; + @override void initState() { super.initState(); @@ -140,7 +144,7 @@ class _SingleColumnDocumentLayoutState extends State DocumentPosition? getDocumentPositionNearestToOffset(Offset rawDocumentOffset) { // Constrain the incoming offset to sit within the width // of this document layout. - final docBox = context.findRenderObject() as RenderBox; + final docBox = boxContext.findRenderObject() as RenderBox; final documentOffset = Offset( // Notice the +1/-1. Experimentally, I determined that if we confine // to the exact width, that x-value is considered outside the @@ -235,7 +239,7 @@ class _SingleColumnDocumentLayoutState extends State final componentEdge = component.getEdgeForPosition(position.nodePosition); final componentBox = component.context.findRenderObject() as RenderBox; - final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); return componentEdge.translate(docOffset.dx, docOffset.dy); } @@ -251,7 +255,7 @@ class _SingleColumnDocumentLayoutState extends State final componentRect = component.getRectForPosition(position.nodePosition); final componentBox = component.context.findRenderObject() as RenderBox; - final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); return componentRect.translate(docOffset.dx, docOffset.dy); } @@ -270,7 +274,7 @@ class _SingleColumnDocumentLayoutState extends State final componentBoundingBoxes = []; // Collect bounding boxes for all selected components. - final documentLayoutBox = context.findRenderObject() as RenderBox; + final documentLayoutBox = boxContext.findRenderObject() as RenderBox; if (base.nodeId == extent.nodeId) { // Selection within a single node. topComponent = extentComponent; @@ -486,7 +490,7 @@ class _SingleColumnDocumentLayoutState extends State /// Returns `null` if there is no overlap. Rect? _getLocalOverlapWithComponent(Rect region, DocumentComponent component) { final componentBox = component.context.findRenderObject() as RenderBox; - final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); final componentBounds = contentOffset & componentBox.size; editorLayoutLog.finest("Component bounds: $componentBounds, versus region of interest: $region"); @@ -603,7 +607,7 @@ class _SingleColumnDocumentLayoutState extends State } bool _isOffsetInComponent(RenderBox componentBox, Offset documentOffset) { - final containerBox = context.findRenderObject() as RenderBox; + final containerBox = boxContext.findRenderObject() as RenderBox; final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: containerBox); final contentRect = contentOffset & componentBox.size; @@ -613,7 +617,7 @@ class _SingleColumnDocumentLayoutState extends State /// Returns the vertical distance between the given [documentOffset] and the /// bounds of the given [componentBox]. double _getDistanceToComponent(RenderBox componentBox, Offset documentOffset) { - final documentLayoutBox = context.findRenderObject() as RenderBox; + final documentLayoutBox = boxContext.findRenderObject() as RenderBox; final componentOffset = componentBox.localToGlobal(Offset.zero, ancestor: documentLayoutBox); final componentRect = componentOffset & componentBox.size; @@ -630,7 +634,7 @@ class _SingleColumnDocumentLayoutState extends State } Offset _componentOffset(RenderBox componentBox, Offset documentOffset) { - final containerBox = context.findRenderObject() as RenderBox; + final containerBox = boxContext.findRenderObject() as RenderBox; final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: containerBox); final contentRect = contentOffset & componentBox.size; @@ -658,17 +662,17 @@ class _SingleColumnDocumentLayoutState extends State @override Offset getDocumentOffsetFromAncestorOffset(Offset ancestorOffset, [RenderObject? ancestor]) { - return (context.findRenderObject() as RenderBox).globalToLocal(ancestorOffset, ancestor: ancestor); + return (boxContext.findRenderObject() as RenderBox).globalToLocal(ancestorOffset, ancestor: ancestor); } @override Offset getAncestorOffsetFromDocumentOffset(Offset documentOffset, [RenderObject? ancestor]) { - return (context.findRenderObject() as RenderBox).localToGlobal(documentOffset, ancestor: ancestor); + return (boxContext.findRenderObject() as RenderBox).localToGlobal(documentOffset, ancestor: ancestor); } @override Offset getGlobalOffsetFromDocumentOffset(Offset documentOffset) { - return (context.findRenderObject() as RenderBox).localToGlobal(documentOffset); + return (boxContext.findRenderObject() as RenderBox).localToGlobal(documentOffset); } @override @@ -706,12 +710,15 @@ class _SingleColumnDocumentLayoutState extends State @override Widget build(BuildContext context) { editorLayoutLog.fine("Building document layout"); - final result = Padding( - padding: widget.presenter.viewModel.padding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: _buildDocComponents(), + final result = SliverToBoxAdapter( + child: Padding( + key: _boxKey, + padding: widget.presenter.viewModel.padding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: _buildDocComponents(), + ), ), ); @@ -849,7 +856,7 @@ class _SingleColumnDocumentLayoutState extends State final component = componentKey.currentState as DocumentComponent; final componentBox = component.context.findRenderObject() as RenderBox; - final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); return contentOffset & componentBox.size; } } diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index dc27394a2e..f252ced672 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -1,6 +1,7 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart' show ValueListenable, defaultTargetPlatform; import 'package:flutter/material.dart' hide SelectableText; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; @@ -33,6 +34,7 @@ import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/infrastructure/signal_notifier.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; import 'package:super_editor/src/undo_redo.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../infrastructure/document_gestures_interaction_overrides.dart'; @@ -135,6 +137,7 @@ class SuperEditor extends StatefulWidget { this.createOverlayControlsClipper, this.plugins = const {}, this.debugPaint = const DebugPaintConfig(), + this.shrinkWrap = false, }) : stylesheet = stylesheet ?? defaultStylesheet, selectionStyles = selectionStyle ?? defaultSelectionStyle, componentBuilders = componentBuilders != null @@ -344,6 +347,10 @@ class SuperEditor extends StatefulWidget { /// debugging. final DebugPaintConfig debugPaint; + /// Whether the scroll view used by the editor should shrink-wrap its contents. + /// Only used when editor is not inside an scrollable. + final bool shrinkWrap; + @override SuperEditorState createState() => SuperEditorState(); } @@ -645,45 +652,43 @@ class SuperEditorState extends State { document: widget.editor.document, selection: _composer.selectionNotifier, isDocumentLayoutAvailable: () => - (_docLayoutKey.currentContext?.findRenderObject() as RenderBox?)?.hasSize == true, + (_docLayoutKey.currentContext?.findRenderObject() as RenderSliver?)?.hasSize == true, getDocumentLayout: () => editContext.documentLayout, placeCaretAtEndOfDocumentOnGainFocus: widget.selectionPolicies.placeCaretAtEndOfDocumentOnGainFocus, restorePreviousSelectionOnGainFocus: widget.selectionPolicies.restorePreviousSelectionOnGainFocus, clearSelectionWhenEditorLosesFocus: widget.selectionPolicies.clearSelectionWhenEditorLosesFocus, - child: _buildTextInputSystem( - child: _buildPlatformSpecificViewportDecorations( - controlsScopeContext, - child: DocumentScaffold( - documentLayoutLink: _documentLayoutLink, - documentLayoutKey: _docLayoutKey, - gestureBuilder: _buildGestureInteractor, - scrollController: _scrollController, - autoScrollController: _autoScrollController, - scroller: _scroller, - presenter: presenter, - componentBuilders: widget.componentBuilders, - underlays: [ - // Add all underlays that the app wants. - for (final underlayBuilder in widget.documentUnderlayBuilders) // - (context) => underlayBuilder.build(context, editContext), - ], - overlays: [ - // Layer that positions and sizes leader widgets at the bounds - // of the users selection so that carets, handles, toolbars, and - // other things can follow the selection. - (context) { - return _SelectionLeadersDocumentLayerBuilder( - links: _selectionLinks, - showDebugLeaderBounds: false, - ).build(context, editContext); - }, - // Add all overlays that the app wants. - for (final overlayBuilder in widget.documentOverlayBuilders) // - (context) => overlayBuilder.build(context, editContext), - ], - debugPaint: widget.debugPaint, - ), - ), + child: DocumentScaffold( + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + viewportDecorationBuilder: _buildPlatformSpecificViewportDecorations, + textInputBuilder: _buildTextInputSystem, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + presenter: presenter, + componentBuilders: widget.componentBuilders, + shrinkWrap: widget.shrinkWrap, + underlays: [ + // Add all underlays that the app wants. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, editContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) { + return _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + showDebugLeaderBounds: false, + ).build(context, editContext); + }, + // Add all overlays that the app wants. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, editContext), + ], + debugPaint: widget.debugPaint, ), ), ); @@ -723,7 +728,8 @@ class SuperEditorState extends State { /// Builds the widget tree that applies user input, e.g., key /// presses from a keyboard, or text deltas from the IME. - Widget _buildTextInputSystem({ + Widget _buildTextInputSystem( + BuildContext context, { required Widget child, }) { switch (inputSource) { diff --git a/super_editor/lib/src/infrastructure/content_layers.dart b/super_editor/lib/src/infrastructure/content_layers.dart index 9299b9b3d1..9e54d3231a 100644 --- a/super_editor/lib/src/infrastructure/content_layers.dart +++ b/super_editor/lib/src/infrastructure/content_layers.dart @@ -3,6 +3,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; /// Widget that displays [content] above a number of [underlays], and beneath a number of /// [overlays]. @@ -345,26 +346,23 @@ class ContentLayersElement extends RenderObjectElement { @override void insertRenderObjectChild(RenderObject child, Object? slot) { - assert(child is RenderBox); assert(slot != null); assert(_isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); - renderObject.insertChild(child as RenderBox, slot!); + renderObject.insertChild(child, slot!); } @override void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { - assert(child is RenderBox); assert(child.parent == renderObject); assert(oldSlot != null); assert(newSlot != null); assert(_isContentLayersSlot(oldSlot!), "Invalid ContentLayers slot: $oldSlot"); assert(_isContentLayersSlot(newSlot!), "Invalid ContentLayers slot: $newSlot"); - if (oldSlot == _contentSlot) { - renderObject.moveChildContentToLayer(child as RenderBox, newSlot!); - } else if (newSlot == _contentSlot) { - renderObject.moveChildFromLayerToContent(child as RenderBox, oldSlot!); + // Can't move renderBox children to and from content slot (which is a sliver) + if (oldSlot == _contentSlot || newSlot == _contentSlot) { + assert(false); } else { renderObject.moveChildLayer(child as RenderBox, oldSlot!, newSlot!); } @@ -377,7 +375,7 @@ class ContentLayersElement extends RenderObjectElement { assert(slot != null); assert(_isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); - renderObject.removeChild(child as RenderBox, slot!); + renderObject.removeChild(child, slot!); } @override @@ -432,7 +430,7 @@ class ContentLayersElement extends RenderObjectElement { /// `RenderObject` for a [ContentLayers] widget. /// /// Must be associated with an `Element` of type [ContentLayersElement]. -class RenderContentLayers extends RenderBox { +class RenderContentLayers extends RenderSliver with RenderSliverHelpers { RenderContentLayers(this._element); @override @@ -444,7 +442,7 @@ class RenderContentLayers extends RenderBox { ContentLayersElement? _element; final _underlays = []; - RenderBox? _content; + RenderSliver? _content; final _overlays = []; /// Whether this render object's layout information or its content @@ -512,46 +510,20 @@ class RenderContentLayers extends RenderBox { return childDiagnostics; } - void insertChild(RenderBox child, Object slot) { + void insertChild(RenderObject child, Object slot) { assert(_isContentLayersSlot(slot)); if (slot == _contentSlot) { - _content = child; + _content = child as RenderSliver; } else if (slot is _UnderlaySlot) { - _underlays.insert(slot.index, child); + _underlays.insert(slot.index, child as RenderBox); } else if (slot is _OverlaySlot) { - _overlays.insert(slot.index, child); + _overlays.insert(slot.index, child as RenderBox); } adoptChild(child); } - void moveChildContentToLayer(RenderBox child, Object newSlot) { - assert(newSlot is _UnderlaySlot || newSlot is _OverlaySlot); - - _content = null; - - if (newSlot is _UnderlaySlot) { - _underlays.insert(newSlot.index, child); - } else if (newSlot is _OverlaySlot) { - _overlays.insert(newSlot.index, child); - } - } - - void moveChildFromLayerToContent(RenderBox child, Object oldSlot) { - assert(oldSlot is _UnderlaySlot || oldSlot is _OverlaySlot); - - if (oldSlot is _UnderlaySlot) { - assert(_underlays.contains(child)); - _underlays.remove(child); - } else if (oldSlot is _OverlaySlot) { - assert(_overlays.contains(child)); - _overlays.remove(child); - } - - _content = child; - } - void moveChildLayer(RenderBox child, Object oldSlot, Object newSlot) { assert(oldSlot is _UnderlaySlot || oldSlot is _OverlaySlot); assert(newSlot is _UnderlaySlot || newSlot is _OverlaySlot); @@ -571,7 +543,7 @@ class RenderContentLayers extends RenderBox { } } - void removeChild(RenderBox child, Object slot) { + void removeChild(RenderObject child, Object slot) { assert(_isContentLayersSlot(slot)); if (slot == _contentSlot) { @@ -600,26 +572,11 @@ class RenderContentLayers extends RenderBox { } } - @override - Size computeDryLayout(BoxConstraints constraints) => _content?.computeDryLayout(constraints) ?? Size.zero; - - @override - double computeMinIntrinsicWidth(double height) => _content?.computeMinIntrinsicWidth(height) ?? 0.0; - - @override - double computeMaxIntrinsicWidth(double height) => _content?.computeMaxIntrinsicWidth(height) ?? 0.0; - - @override - double computeMinIntrinsicHeight(double width) => _content?.computeMinIntrinsicHeight(width) ?? 0.0; - - @override - double computeMaxIntrinsicHeight(double width) => _content?.computeMaxIntrinsicHeight(width) ?? 0.0; - @override void performLayout() { contentLayersLog.info("Laying out ContentLayers"); if (_content == null) { - size = Size.zero; + geometry = SliverGeometry.zero; _contentNeedsLayout = false; return; } @@ -628,11 +585,26 @@ class RenderContentLayers extends RenderBox { // Always layout the content first, so that layers can inspect the content layout. contentLayersLog.fine("Laying out content - $_content"); + (_content!.parentData! as SliverLogicalParentData).layoutOffset = 0.0; _content!.layout(constraints, parentUsesSize: true); contentLayersLog.fine("Content after layout: $_content"); // The size of the layers, and the our size, is exactly the same as the content. - size = _content!.size; + final SliverGeometry sliverLayoutGeometry = _content!.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); _contentNeedsLayout = false; @@ -649,13 +621,23 @@ class RenderContentLayers extends RenderBox { contentLayersLog.fine("Laying out layers (${_underlays.length} underlays, ${_overlays.length} overlays)"); // Layout the layers below and above the content. - final layerConstraints = BoxConstraints.tight(size); + final layerConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); for (final underlay in _underlays) { + final childParentData = underlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; contentLayersLog.fine("Laying out underlay: $underlay"); underlay.layout(layerConstraints); } for (final overlay in _overlays) { + final childParentData = overlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; contentLayersLog.fine("Laying out overlay: $overlay"); overlay.layout(layerConstraints); } @@ -665,7 +647,11 @@ class RenderContentLayers extends RenderBox { } @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { if (_content == null) { return false; } @@ -673,23 +659,27 @@ class RenderContentLayers extends RenderBox { // Run hit tests in reverse-paint order. bool didHit = false; + final boxResult = BoxHitTestResult.wrap(result); + // First, hit-test overlays. for (final overlay in _overlays) { - didHit = overlay.hitTest(result, position: position); + didHit = + hitTestBoxChild(boxResult, overlay, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); if (didHit) { return true; } } // Second, hit-test the content. - didHit = _content!.hitTest(result, position: position); + didHit = _content!.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); if (didHit) { return true; } // Third, hit-test the underlays. for (final underlay in _underlays) { - didHit = underlay.hitTest(result, position: position) || didHit; + didHit = hitTestBoxChild(boxResult, underlay, + mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); if (didHit) { return true; } @@ -704,19 +694,44 @@ class RenderContentLayers extends RenderBox { return; } + void paintChild(RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + } + // First, paint the underlays. for (final underlay in _underlays) { - context.paintChild(underlay, offset); + paintChild(underlay); } // Second, paint the content. - context.paintChild(_content!, offset); + paintChild(_content!); // Third, paint the overlays. for (final overlay in _overlays) { - context.paintChild(overlay, offset); + paintChild(overlay); } } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } } bool _isContentLayersSlot(Object slot) => slot == _contentSlot || slot is _UnderlaySlot || slot is _OverlaySlot; @@ -967,3 +982,5 @@ abstract class ContentLayerState {} diff --git a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart index 0e36259c63..4ac084d262 100644 --- a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart +++ b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -5,7 +5,8 @@ import 'package:super_editor/src/default_editor/layout_single_column/_layout.dar import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; -import 'package:super_editor/src/infrastructure/viewport_size_reporting.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; /// A scaffold that combines pieces to create a scrolling single-column document, with /// gestures placed beneath the document. @@ -17,12 +18,15 @@ class DocumentScaffold extends StatefulWidget { super.key, required this.documentLayoutLink, required this.documentLayoutKey, + required this.viewportDecorationBuilder, required this.gestureBuilder, + this.textInputBuilder, this.scrollController, required this.autoScrollController, required this.scroller, required this.presenter, required this.componentBuilders, + required this.shrinkWrap, this.underlays = const [], this.overlays = const [], this.debugPaint = const DebugPaintConfig(), @@ -38,6 +42,13 @@ class DocumentScaffold extends StatefulWidget { /// beneath the document, at the same size as the viewport. final WidgetBuilder gestureBuilder; + /// Builds the text input widget, if applicable. The text input system is placed + /// above the gesture system and beneath viewport decoration. + final Widget Function(BuildContext context, {required Widget child})? textInputBuilder; + + /// Builds platform specific viewport decoration (such as toolbar overlay manager or magnifier overlay manager). + final Widget Function(BuildContext context, {required Widget child}) viewportDecorationBuilder; + /// Controls scrolling when this [DocumentScaffold] adds its own `Scrollable`, but /// doesn't provide scrolling control when this [DocumentScaffold] uses an ancestor /// `Scrollable`. @@ -71,6 +82,10 @@ class DocumentScaffold extends StatefulWidget { /// Paints some extra visual ornamentation to help with debugging. final DebugPaintConfig debugPaint; + /// Whether the document should shrink-wrap its content. + /// Only used when the document is not inside a scrollable. + final bool shrinkWrap; + @override State createState() => _DocumentScaffoldState(); } @@ -78,9 +93,16 @@ class DocumentScaffold extends StatefulWidget { class _DocumentScaffoldState extends State { @override Widget build(BuildContext context) { + var child = _buildGestureSystem( + child: _buildDocumentLayout(), + ); + if (widget.textInputBuilder != null) { + child = widget.textInputBuilder!(context, child: child); + } return _buildDocumentScrollable( - child: _buildGestureSystem( - child: _buildDocumentLayout(), + child: widget.viewportDecorationBuilder( + context, + child: child, ), ); } @@ -91,66 +113,54 @@ class _DocumentScaffoldState extends State { Widget _buildDocumentScrollable({ required Widget child, }) { - return ViewportBoundsReporter( - viewportOuterConstraints: _contentConstraints, - child: DocumentScrollable( - autoScroller: widget.autoScrollController, - scrollController: widget.scrollController, - scrollingMinimapId: widget.debugPaint.scrollingMinimapId, - scroller: widget.scroller, - showDebugPaint: widget.debugPaint.scrolling, - child: child, - ), + return DocumentScrollable( + autoScroller: widget.autoScrollController, + scrollController: widget.scrollController, + scrollingMinimapId: widget.debugPaint.scrollingMinimapId, + scroller: widget.scroller, + shrinkWrap: widget.shrinkWrap, + showDebugPaint: widget.debugPaint.scrolling, + child: child, ); } - final _contentConstraints = ValueNotifier(const BoxConstraints()); - /// Builds the widget tree that handles user gesture interaction /// with the document, e.g., mouse input on desktop, or touch input /// on mobile. Widget _buildGestureSystem({ required Widget child, }) { - return ViewportBoundsReplicator( - viewportOuterConstraints: _contentConstraints, - child: Stack( - clipBehavior: Clip.none, - children: [ - // A layer that sits beneath the document and handles gestures. - // It's beneath the document so that components that include - // interactive UI, like a Checkbox, can intercept their own - // touch events. - // - // This layer is placed outside of `ContentLayers` because this - // layer needs to be wider than the document, to fill all available - // space. - Positioned.fill( - child: widget.gestureBuilder(context), - ), - child, - ], - ), + final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; + return SliverHybridStack( + // Ensure that gesture object fill entire viewport when not being + // in user specified scrollable. + fillViewport: ancestorScrollable == null, + children: [ + // A layer that sits beneath the document and handles gestures. + // It's beneath the document so that components that include + // interactive UI, like a Checkbox, can intercept their own + // touch events. + // + // This layer is placed outside of `ContentLayers` because this + // layer needs to be wider than the document, to fill all available + // space. + widget.gestureBuilder(context), + child, + ], ); } Widget _buildDocumentLayout() { - return Align( - alignment: Alignment.topCenter, - child: CompositedTransformTarget( - link: widget.documentLayoutLink, - child: ContentLayers( - content: (onBuildScheduled) => SingleColumnDocumentLayout( - key: widget.documentLayoutKey, - presenter: widget.presenter, - componentBuilders: widget.componentBuilders, - onBuildScheduled: onBuildScheduled, - showDebugPaint: widget.debugPaint.layout, - ), - underlays: widget.underlays, - overlays: widget.overlays, - ), + return ContentLayers( + content: (onBuildScheduled) => SingleColumnDocumentLayout( + key: widget.documentLayoutKey, + presenter: widget.presenter, + componentBuilders: widget.componentBuilders, + onBuildScheduled: onBuildScheduled, + showDebugPaint: widget.debugPaint.layout, ), + underlays: widget.underlays, + overlays: widget.overlays, ); } } diff --git a/super_editor/lib/src/infrastructure/flutter/build_context.dart b/super_editor/lib/src/infrastructure/flutter/build_context.dart index 2ac5b13131..84eed59523 100644 --- a/super_editor/lib/src/infrastructure/flutter/build_context.dart +++ b/super_editor/lib/src/infrastructure/flutter/build_context.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; extension ScrollableFinder on BuildContext { /// Finds the nearest ancestor [Scrollable] with a vertical scroll in the @@ -18,4 +19,20 @@ extension ScrollableFinder on BuildContext { return ancestorScrollable; } + + /// Returns the RenderBox of the nearest ancestor [RenderAbstractViewport]. + RenderBox findViewportBox() { + // findAncestorRenderObjectOfType traverses the element tree, which is + // more dense then render object tree. So instead we traverse the + // render object tree. + var renderObject = findRenderObject(); + while (renderObject != null) { + if (renderObject is RenderAbstractViewport) { + return renderObject as RenderBox; + } + renderObject = renderObject.parent; + } + + throw StateError('No RenderAbstractViewport ancestor found'); + } } diff --git a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart index de7d02739c..a56b1f7e1b 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; @@ -14,6 +15,7 @@ import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_text_layout/super_text_layout.dart'; /// A document layer that positions a leader widget around the user's selection, @@ -335,7 +337,7 @@ class AndroidControlsDocumentLayerState // The computeLayoutData method is called during the layer's build, which means that the // layer's RenderBox is outdated, because it wasn't laid out yet for the current frame. // Use the content's RenderBox, which was already laid out for the current frame. - final contentBox = documentContext.findRenderObject() as RenderBox?; + final contentBox = documentContext.findRenderObject() as RenderSliver?; if (contentBox != null && contentBox.hasSize && caretRect.left + caretWidth >= contentBox.size.width) { // Ajust the caret position to make it entirely visible because it's currently placed // partially or entirely outside of the layers' bounds. This can happen for downstream selections diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart index d02b367160..8ed0517541 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; @@ -15,6 +16,7 @@ import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -136,17 +138,7 @@ class IosDocumentGestureEditingController extends GestureEditingController { required super.selectionLinks, required super.magnifierFocalPointLink, required super.overlayController, - }) : _documentLayoutLink = documentLayoutLink; - - /// Layer link that's aligned to the top-left corner of the document layout. - /// - /// Some of the offsets reported by this controller are based on the - /// document layout coordinate space. Therefore, to honor those offsets on - /// the screen, this `LayerLink` should be used to align the controls with - /// the document layout before applying the offset that sits within the - /// document layout. - LayerLink get documentLayoutLink => _documentLayoutLink; - final LayerLink _documentLayoutLink; + }); /// Whether or not a caret should be displayed. bool get hasCaret => caretTop != null; @@ -745,7 +737,7 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState= contentBox.size.width) { // Ajust the caret position to make it entirely visible because it's currently placed // partially or entirely outside of the layers' bounds. This can happen for downstream selections diff --git a/super_editor/lib/src/infrastructure/render_sliver_ext.dart b/super_editor/lib/src/infrastructure/render_sliver_ext.dart new file mode 100644 index 0000000000..89a64b91b4 --- /dev/null +++ b/super_editor/lib/src/infrastructure/render_sliver_ext.dart @@ -0,0 +1,28 @@ +import 'package:flutter/rendering.dart'; + +/// Extension on [RenderSliver] that that brings over some of the missing +/// [RenderBox] functionality. +extension RenderSliverExt on RenderSliver { + Size get size { + assert(attached); + return Size(geometry!.crossAxisExtent ?? constraints.crossAxisExtent, geometry!.paintExtent); + } + + bool get hasSize { + assert(attached); + return geometry != null; + } + + Offset globalToLocal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + transform.invert(); + return MatrixUtils.transformPoint(transform, point); + } + + Offset localToGlobal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + return MatrixUtils.transformPoint(transform, point); + } +} diff --git a/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart b/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart new file mode 100644 index 0000000000..441ef54b56 --- /dev/null +++ b/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart @@ -0,0 +1,206 @@ +import "package:flutter/rendering.dart"; +import "package:flutter/widgets.dart"; + +/// Component that allows mixing RenderSliver child with other RenderBox +/// children. The RenderSliver child will be laid out first, and then the +/// RenderBox children will be laid out to cover the entire scroll extent +/// of the RenderSliver child. +class SliverHybridStack extends MultiChildRenderObjectWidget { + /// Creates a SliverHybridStack. The [children] must contain exactly one + /// child that a RenderSliver, and zero or more RenderBox children. + /// The [fillViewport] flag controls whether the RenderBox children should + /// be stretched if necessary to fill the entire viewport. + const SliverHybridStack({ + super.key, + this.fillViewport = false, + super.children, + }); + + final bool fillViewport; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSliverHybridStack(fillViewport: fillViewport); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderSliver renderObject) { + (renderObject as _RenderSliverHybridStack).fillViewport = fillViewport; + } +} + +class _ChildParentData extends SliverLogicalParentData with ContainerParentDataMixin {} + +class _RenderSliverHybridStack extends RenderSliver + with ContainerRenderObjectMixin>, RenderSliverHelpers { + _RenderSliverHybridStack({required this.fillViewport}); + + bool fillViewport; + + @override + void performLayout() { + RenderSliver? sliver; + var child = firstChild; + while (child != null) { + if (child is RenderSliver) { + assert(sliver == null, "There can only be one sliver in a SliverHybridStack"); + sliver = child; + break; + } + child = childAfter(child); + } + if (sliver == null) { + geometry = SliverGeometry.zero; + return; + } + + (sliver.parentData! as SliverLogicalParentData).layoutOffset = 0.0; + sliver.layout(constraints, parentUsesSize: true); + final SliverGeometry sliverLayoutGeometry = sliver.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); + + final boxConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); + + child = firstChild; + while (child != null) { + if (child is RenderBox) { + final childParentData = child.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + if (constraints.scrollOffset == 0.0 && fillViewport) { + child.layout( + BoxConstraints.tightFor( + width: constraints.crossAxisExtent, + height: constraints.viewportMainAxisExtent, + ), + parentUsesSize: true, + ); + } else { + child.layout(boxConstraints, parentUsesSize: true); + } + } + child = childAfter(child); + } + } + + @override + bool hitTest(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { + if (mainAxisPosition >= 0.0 && crossAxisPosition >= 0.0 && crossAxisPosition < constraints.crossAxisExtent) { + if (hitTestChildren(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition) || + hitTestSelf(mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) { + result.add(SliverHitTestEntry( + this, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + )); + return true; + } + } + return false; + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + var child = lastChild; + while (child != null) { + if (child is RenderSliver) { + final isHit = child.hitTest( + result, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } else if (child is RenderBox) { + final boxResult = BoxHitTestResult.wrap(result); + final isHit = hitTestBoxChild( + boxResult, + child, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } + child = childBefore(child); + } + return false; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } + + @override + void paint(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + child = childAfter(child); + } + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } +} + +// Box constraints that will cause relayout when the scroll offset changes. +class ScrollingBoxConstraints extends BoxConstraints { + const ScrollingBoxConstraints({ + super.minWidth, + super.maxWidth, + super.minHeight, + super.maxHeight, + required this.scrollOffset, + }); + + final double scrollOffset; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ScrollingBoxConstraints && super == other && scrollOffset == other.scrollOffset; + } + + @override + int get hashCode => Object.hash(super.hashCode, scrollOffset); +} diff --git a/super_editor/lib/src/infrastructure/viewport_size_reporting.dart b/super_editor/lib/src/infrastructure/viewport_size_reporting.dart deleted file mode 100644 index 1e2c842ef0..0000000000 --- a/super_editor/lib/src/infrastructure/viewport_size_reporting.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -/// Reports layout constraints from outside a viewport, so that those constraints -/// can be used inside of a viewport. -/// -/// This widget was designed to solve a nuanced problem within `SuperEditor` and -/// `SuperReader`. For the purpose of this explanation, `SuperEditor` will be -/// used to refer to both `SuperEditor` and `SuperReader`. -/// -/// ## The Problem -/// `SuperEditor` needs to use a special pair of widgets to size its gesture -/// area due to the presence of a scrollable viewport in the middle of `SuperEditor`'s -/// widget tree. To understand why this complexity is needed, the following -/// points are important to understand: -/// -/// 1. `SuperEditor` places its gesture detector BEHIND the document so that -/// individual components in the document have the first chance to handle -/// taps, drags, etc. -/// 2. Individual components within the document layout need to be able to -/// respond to gestures. -/// 3. The document layout sits inside of a scrollable viewport. -/// -/// Given these invariants, the question becomes: where do we place `SuperEditor`'s -/// gesture system, and how do we make it cover the full `SuperEditor` bounds? -/// -/// The following are some options that we've considered, but won't work. -/// -/// ### Place gestures around the scrollable viewport -/// -/// _buildGestureSystem( -/// child: IgnorePointer( -/// child DocumentScrollable( -/// child: DocumentLayout(), -/// ), -/// ), -/// ); -/// -/// In this approach we place the gesture system behind the scrollable viewport. -/// This approach gives us the correct size for the gesture bounds. But, for -/// touch events to get back to the gesture system, we have to `IgnorePointer` -/// around the scrollable, so that the scrollable doesn't steal all the gestures. -/// Unfortunately, if we ignore gestures for the scrollable, it forces us to also -/// ignore gestures within the document layout, which violates requirement #2. -/// -/// ### Gesture area inside the scrollable with LayoutBuilder for size -/// -/// LayoutBuilder( -/// builder: (context, constraints) { -/// final viewportSize = constraints.biggest; -/// -/// return Stack( -/// children: [ -/// DocumentScrollable( -/// child: DocumentLayout(), -/// ), -/// _buildGestureSystem(viewportSize), -/// ], -/// ); -/// }, -/// ); -/// -/// In this approach, we place a `LayoutBuilder` outside of the scrollable, which then -/// tells us the size of the viewport. We provide that size to the gesture system, which -/// sits INSIDE the scrollable, and the gesture system makes itself exactly the same -/// size as the viewport. -/// -/// This approach works, but it has a downside. We can't calculate an intrinsic height -/// for `SuperEditor`, because `LayoutBuilder` throws an exception when calculating -/// intrinsic height. We felt it was important to be able to calculate intrinsic height. -/// -/// ## The Solution -/// To solve this problem we introduce two widgets, which are connected by a notifier. -/// -/// The first widget, [ViewportBoundsReporter], measures the available space OUTSIDE the -/// scrollable viewport during layout, and reports it to the notifier. -/// -/// The second widget, [ViewportBoundsReplicator], sits INSIDE the scrollable where -/// the vertical constraint is infinite. During layout, this widget reads the size -/// info from the notifier and sizes itself based on those constraints, instead of -/// using its incoming constraints. -/// -/// As a result, the gesture area makes itself exactly the same size as the viewport -/// that surrounds it. -/// -/// Intended use: -/// -/// ViewportBoundsReporter( -/// contentConstraints: gestureConstraintsNotifier, -/// // This is the scrollable viewport. -/// child: DocumentScrollable( -/// child: MoreSubTree( -/// child: Stack( -/// children: [ -/// // This gesture system needs to be as tall as the -/// // DocumentScrollable ancestor above, and it needs -/// // to sit behind the document layout. -/// ViewportBoundsReplicator( -/// contentConstraints: gestureConstraintsNotifier, -/// child: _buildGestureSystem(), -/// ), -/// // This is the document layout, which contains the -/// // individual components that need to have the first -/// // change to respond to gestures. -/// _buildDocumentLayout(), -/// ), -/// ), -/// ), -/// ), -/// ); -/// -/// See also: -/// * [ViewportBoundsReplicator] - which constrains itself with the constraints -/// selected by this widget. -class ViewportBoundsReporter extends SingleChildRenderObjectWidget { - const ViewportBoundsReporter({ - required this.viewportOuterConstraints, - required super.child, - }); - - /// The layout constraints that apply outside of the scrollable viewport. - /// - /// This widget is expected to build around the scrollable viewport. This - /// widget then reports its layout constraints to this notifier to be used - /// by a descendant [ViewportBoundsReplicator]. - final ValueNotifier viewportOuterConstraints; - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderViewportBoundsReporter() // - ..viewportOuterConstraints = viewportOuterConstraints; - } - - @override - void updateRenderObject(BuildContext context, RenderViewportBoundsReporter renderObject) { - renderObject.viewportOuterConstraints = viewportOuterConstraints; - } -} - -class RenderViewportBoundsReporter extends RenderProxyBox { - late ValueNotifier viewportOuterConstraints; - - @override - void performLayout() { - // We must report the desired constraints before running layout on our child, - // because these constraints will impact the child's desired size. This impact is - // indirect. The widget that uses these content constraints is probably a - // deep descendant of our `child`. - viewportOuterConstraints.value = BoxConstraints( - minWidth: constraints.maxWidth < double.infinity ? constraints.maxWidth : 0, - minHeight: constraints.maxHeight < double.infinity ? constraints.maxHeight : 0, - ); - - child!.layout(constraints, parentUsesSize: true); - size = child!.size; - } -} - -/// A widget that sizes itself based on [viewportOuterConstraints], rather than its -/// incoming layout constraints. -/// -/// See [ViewportBoundsReporter] for an explanation about why that widget and this -/// widget are necessary. -class ViewportBoundsReplicator extends SingleChildRenderObjectWidget { - const ViewportBoundsReplicator({ - required this.viewportOuterConstraints, - required super.child, - }); - - /// The layout constraints that apply outside of the scrollable viewport. - /// - /// This widget attempts to apply [viewportOuterConstraints] to itself, instead - /// of its incoming layout constraints. - final ValueNotifier viewportOuterConstraints; - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderViewportBoundsReplicator()..viewportOuterConstraints = viewportOuterConstraints; - } - - @override - void updateRenderObject(BuildContext context, RenderViewportBoundsReplicator renderObject) { - renderObject.viewportOuterConstraints = viewportOuterConstraints; - } -} - -class RenderViewportBoundsReplicator extends RenderProxyBox { - // Note: we don't need to listen to changes because the constraints only - // change when our ancestor runs layout. If our ancestor runs layout, then - // we will run layout, too. - late ValueNotifier viewportOuterConstraints; - - @override - void performLayout() { - final childConstraints = viewportOuterConstraints.value.enforce(constraints); - - child!.layout(childConstraints, parentUsesSize: true); - size = child!.size; - } -} diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index 89ab9ef789..a7084f5888 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -446,9 +446,7 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State - (context.findAncestorScrollableWithVerticalScroll?.context.findRenderObject() ?? context.findRenderObject()) - as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); diff --git a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart index c03b5d9ace..350f24f901 100644 --- a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart @@ -21,6 +21,7 @@ import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selecti import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_editor/src/super_reader/reader_context.dart'; import 'package:super_editor/src/super_reader/super_reader.dart'; @@ -413,9 +414,7 @@ class _SuperReaderIosDocumentTouchInteractorState extends State - (context.findAncestorScrollableWithVerticalScroll?.context.findRenderObject() ?? context.findRenderObject()) - as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); RenderBox get interactorBox => context.findRenderObject() as RenderBox; @@ -1086,10 +1085,15 @@ class SuperReaderIosToolbarOverlayManagerState extends State createState() => SuperReaderState(); } @@ -390,35 +395,34 @@ class SuperReaderState extends State { readerContext: _readerContext, keyboardActions: widget.keyboardActions, autofocus: widget.autofocus, - child: _buildPlatformSpecificViewportDecorations( - controlsScopeContext, - child: DocumentScaffold( - documentLayoutLink: _documentLayoutLink, - documentLayoutKey: _docLayoutKey, - gestureBuilder: _buildGestureInteractor, - scrollController: _scrollController, - autoScrollController: _autoScrollController, - scroller: _scroller, - presenter: _docLayoutPresenter!, - componentBuilders: widget.componentBuilders, - underlays: [ - // Add any underlays that were provided by the client. - for (final underlayBuilder in widget.documentUnderlayBuilders) // - (context) => underlayBuilder.build(context, _readerContext), - ], - overlays: [ - // Layer that positions and sizes leader widgets at the bounds - // of the users selection so that carets, handles, toolbars, and - // other things can follow the selection. - (context) => _SelectionLeadersDocumentLayerBuilder( - links: _selectionLinks, - ).build(context, _readerContext), - // Add any overlays that were provided by the client. - for (final overlayBuilder in widget.documentOverlayBuilders) // - (context) => overlayBuilder.build(context, _readerContext), - ], - debugPaint: widget.debugPaint, - ), + child: DocumentScaffold( + viewportDecorationBuilder: _buildPlatformSpecificViewportDecorations, + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + presenter: _docLayoutPresenter!, + componentBuilders: widget.componentBuilders, + shrinkWrap: widget.shrinkWrap, + underlays: [ + // Add any underlays that were provided by the client. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, _readerContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) => _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + ).build(context, _readerContext), + // Add any overlays that were provided by the client. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, _readerContext), + ], + debugPaint: widget.debugPaint, ), ); }), diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 482c24b7c5..26f6f9dc08 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -87,7 +87,6 @@ export 'src/infrastructure/strings.dart'; export 'src/super_textfield/super_textfield.dart'; export 'src/infrastructure/touch_controls.dart'; export 'src/infrastructure/text_input.dart'; -export 'src/infrastructure/viewport_size_reporting.dart'; export 'src/infrastructure/popovers.dart'; export 'src/infrastructure/selectable_list.dart'; export 'src/infrastructure/actions.dart'; diff --git a/super_editor/test/infrastructure/content_layers_test.dart b/super_editor/test/infrastructure/content_layers_test.dart index 4c793f58a5..fa5224ddb4 100644 --- a/super_editor/test/infrastructure/content_layers_test.dart +++ b/super_editor/test/infrastructure/content_layers_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; @@ -13,15 +14,18 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => LayoutBuilder( - builder: (context, constraints) { - // The content should be able to take up whatever size it wants, within the available space. - expect(constraints.isTight, isFalse); - expect(constraints.maxWidth, _windowSize.width); - expect(constraints.maxHeight, _windowSize.height); - - return const SizedBox.expand(); - }, + content: (_) => SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + // The content should be able to take up whatever width it wants, within the available space. + // The height is infinite because `ContentLayers` is a sliver. + expect(constraints.isTight, isFalse); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, double.infinity); + + return SizedBox.fromSize(size: _windowSize); + }, + ), ), ), ); @@ -33,7 +37,9 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => const SizedBox.expand(), + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), underlays: [ _buildSizeValidatingLayer(), ], @@ -47,7 +53,9 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => const SizedBox.expand(), + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), overlays: [ _buildSizeValidatingLayer(), ], @@ -61,7 +69,9 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => const SizedBox.expand(), + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), underlays: [ _buildSizeValidatingLayer(), ], @@ -78,7 +88,9 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => const SizedBox.expand(), + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), underlays: [ _buildSizeValidatingLayer(), _buildSizeValidatingLayer(), @@ -112,7 +124,9 @@ void main() { rebuildSignal: contentRebuildSignal, buildTracker: contentBuildTracker, onBuildScheduled: onBuildScheduled, - child: const SizedBox(), + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), ), underlays: [ (context) => _RebuildableContentLayerWidget( @@ -158,7 +172,9 @@ void main() { onLayout: () { didContentLayout.value = true; }, - child: const SizedBox.expand(), + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), ), underlays: [ (context) { @@ -201,7 +217,9 @@ void main() { onLayout: () { contentLayoutCount.value += 1; }, - child: const SizedBox.expand(), + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), ), ), underlays: [ @@ -258,7 +276,9 @@ void main() { onLayout: () { contentLayoutCount.value += 1; }, - child: const SizedBox.expand(), + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), ), ), ), @@ -320,7 +340,9 @@ void main() { onLayout: () { contentLayoutCount.value += 1; }, - child: const SizedBox.expand(), + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), ), ), underlays: [ @@ -328,10 +350,13 @@ void main() { elementTracker: underlayElementTracker, onBuild: () { // Ensure that this layer can access the render object of the content. - final contentBox = contentKey.currentContext!.findRenderObject() as RenderBox?; - expect(contentBox, isNotNull); - expect(contentBox!.hasSize, isTrue); - expect(contentBox.localToGlobal(Offset.zero), isNotNull); + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); }, child: const SizedBox.expand(), ), @@ -341,10 +366,13 @@ void main() { elementTracker: overlayElementTracker, onBuild: () { // Ensure that this layer can access the render object of the content. - final contentBox = contentKey.currentContext!.findRenderObject() as RenderBox?; - expect(contentBox, isNotNull); - expect(contentBox!.hasSize, isTrue); - expect(contentBox.localToGlobal(Offset.zero), isNotNull); + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); }, child: const SizedBox.expand(), ), @@ -375,7 +403,9 @@ void main() { await _pumpScaffold( tester, child: ContentLayers( - content: (_) => const SizedBox.expand(), + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), underlays: [ (context) { // Ensure that this layer can access ancestors. @@ -466,7 +496,11 @@ Future _pumpScaffold( await tester.pumpWidget( MaterialApp( home: Scaffold( - body: child, + body: CustomScrollView( + slivers: [ + child, + ], + ), ), ), ); @@ -745,7 +779,7 @@ class _LayoutTrackingWidget extends SingleChildRenderObjectWidget { } } -class _RenderLayoutTrackingWidget extends RenderProxyBox { +class _RenderLayoutTrackingWidget extends RenderProxySliver { _RenderLayoutTrackingWidget(this._onLayout); final VoidCallback _onLayout; diff --git a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart index 39387ed1f3..3b532b7b56 100644 --- a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart @@ -329,7 +329,7 @@ void main() { childCount: 50, ), ), - SliverToBoxAdapter(child: superEditor), + superEditor, ], ), ), diff --git a/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart b/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart index 9b5f33ae17..5d5ac949c5 100644 --- a/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart +++ b/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart @@ -2524,9 +2524,7 @@ Future _pumpPageScrollSliverTestSetup( ), expandedHeight: 200.0, ), - SliverToBoxAdapter( - child: superEditor, - ), + superEditor, SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { diff --git a/super_editor/test/super_editor/supereditor_scrolling_test.dart b/super_editor/test/super_editor/supereditor_scrolling_test.dart index 524f67cab8..6a5093ef6f 100644 --- a/super_editor/test/super_editor/supereditor_scrolling_test.dart +++ b/super_editor/test/super_editor/supereditor_scrolling_test.dart @@ -64,6 +64,7 @@ void main() { // Jump to the end of the document scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -1071,7 +1072,7 @@ void main() { // Drag an arbitrary amount of pixels from the top of the editor. final dragGesture = await tester.dragByFrameCount( - startLocation: tester.getRect(find.byType(SuperEditor)).topCenter + const Offset(0, 5), + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), totalDragOffset: const Offset(0, 400.0), ); @@ -1524,15 +1525,13 @@ class _SliverTestEditorState extends State<_SliverTestEditor> { textAlign: TextAlign.center, ), ), - SliverToBoxAdapter( - child: SuperEditor( - editor: _docEditor, - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), - ), - gestureMode: widget.gestureMode, - inputSource: TextInputSource.ime, + SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), ), + gestureMode: widget.gestureMode, + inputSource: TextInputSource.ime, ), SliverList( delegate: SliverChildBuilderDelegate( diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 0523b97983..9437ea9d41 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -103,7 +103,7 @@ void main() { // directions: right-to-left is upstream for a single line, and up-to-down is // downstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(200, 35), const Offset(150, 45)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1100, 35), const Offset(1050, 45)); expect(selection, isNotNull); // Ensure that the document selection is upstream. @@ -129,7 +129,7 @@ void main() { // directions: left-to-right is downstream for a single line, and down-to-up is // upstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(150, 45), const Offset(200, 35)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1050, 45), const Offset(1100, 35)); expect(selection, isNotNull); // Ensure that the document selection is downstream. diff --git a/super_editor/test/super_editor/supereditor_switching_test.dart b/super_editor/test/super_editor/supereditor_switching_test.dart index 548e061c32..6934950b96 100644 --- a/super_editor/test/super_editor/supereditor_switching_test.dart +++ b/super_editor/test/super_editor/supereditor_switching_test.dart @@ -101,13 +101,11 @@ class _EditorReaderSwitchDemoState extends State<_EditorReaderSwitchDemo> { controller: widget.scrollController, slivers: [ const SliverAppBar(), - SliverToBoxAdapter( - child: ListenableBuilder( - listenable: widget.isEditable, - builder: (context, _) { - return _buildEditorOrReader(); - }, - ), + ListenableBuilder( + listenable: widget.isEditable, + builder: (context, _) { + return _buildEditorOrReader(); + }, ) ], ), diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 2a486b466b..2b1b108a13 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -507,9 +507,7 @@ class TestSuperEditorConfigurator { return CustomScrollView( controller: _config.scrollController, slivers: [ - SliverToBoxAdapter( - child: child, - ), + child, ], ); } diff --git a/super_editor/test/super_reader/reader_test_tools.dart b/super_editor/test/super_reader/reader_test_tools.dart index 1ca547eacd..36daa0612e 100644 --- a/super_editor/test/super_reader/reader_test_tools.dart +++ b/super_editor/test/super_reader/reader_test_tools.dart @@ -325,9 +325,7 @@ class TestDocumentConfigurator { return CustomScrollView( controller: _scrollController, slivers: [ - SliverToBoxAdapter( - child: child, - ), + child, ], ); } diff --git a/super_editor/test/super_reader/super_reader_scrolling_test.dart b/super_editor/test/super_reader/super_reader_scrolling_test.dart index e8eb6f1709..564dc85ff2 100644 --- a/super_editor/test/super_reader/super_reader_scrolling_test.dart +++ b/super_editor/test/super_reader/super_reader_scrolling_test.dart @@ -60,6 +60,7 @@ void main() { // Jump to the end of the document scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -175,7 +176,7 @@ void main() { DocumentSelection( base: DocumentPosition( nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition.copyWith(affinity: TextAffinity.upstream), + nodePosition: lastParagraph.endPosition, ), extent: DocumentPosition( nodeId: firstParagraph.id, @@ -566,7 +567,7 @@ void main() { // Drag an arbitrary amount of pixels from the top of the reader. final dragGesture = await tester.dragByFrameCount( - startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + startLocation: tester.getRect(find.byType(Viewport)).topCenter + const Offset(0, 5), totalDragOffset: const Offset(0, 400.0), ); diff --git a/super_editor/test/super_reader/super_reader_selection_test.dart b/super_editor/test/super_reader/super_reader_selection_test.dart index 917aa6d04b..d650b555ea 100644 --- a/super_editor/test/super_reader/super_reader_selection_test.dart +++ b/super_editor/test/super_reader/super_reader_selection_test.dart @@ -25,7 +25,7 @@ void main() { // directions: right-to-left is upstream for a single line, and up-to-down is // downstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(200, 35), const Offset(150, 45)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1100, 35), const Offset(1050, 45)); expect(selection, isNotNull); // Ensure that the document selection is upstream. @@ -51,7 +51,7 @@ void main() { // directions: left-to-right is downstream for a single line, and down-to-up is // upstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(150, 45), const Offset(200, 35)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1050, 45), const Offset(1100, 35)); expect(selection, isNotNull); // Ensure that the document selection is downstream. diff --git a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart index c8e30316c7..c002839b50 100644 --- a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart +++ b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart @@ -704,9 +704,7 @@ Widget _buildScaffold({ child: MaterialApp( home: Scaffold( body: Center( - child: IntrinsicHeight( - child: child, - ), + child: child, ), ), debugShowCheckedModeBanner: false,