diff --git a/super_editor/lib/src/core/document_layout.dart b/super_editor/lib/src/core/document_layout.dart index a28c57fd65..0b22776d03 100644 --- a/super_editor/lib/src/core/document_layout.dart +++ b/super_editor/lib/src/core/document_layout.dart @@ -107,6 +107,11 @@ abstract class DocumentLayout { /// Returns the [DocumentPosition] at the end of the last selectable component. DocumentPosition? findLastSelectablePosition(); + + /// Returns whether the component with the given [nodeId] is visible. + /// + /// For example, this method returns `false` if the node is collapsed. + bool isComponentVisible(String nodeId); } /// Contract for all widgets that operate as document components diff --git a/super_editor/lib/src/default_editor/common_editor_operations.dart b/super_editor/lib/src/default_editor/common_editor_operations.dart index b67981b517..7836f16ba3 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -825,9 +825,11 @@ class CommonEditorOperations { selectableNode = document.getNodeBefore(prevNode); if (selectableNode != null) { - final nextComponent = documentLayoutResolver().getComponentByNodeId(selectableNode.id); + final documentLayout = documentLayoutResolver(); + final nextComponent = documentLayout.getComponentByNodeId(selectableNode.id); if (nextComponent != null) { - foundSelectableNode = nextComponent.isVisualSelectionSupported(); + foundSelectableNode = + documentLayout.isComponentVisible(selectableNode.id) && nextComponent.isVisualSelectionSupported(); } prevNode = selectableNode; } @@ -846,9 +848,11 @@ class CommonEditorOperations { selectableNode = document.getNodeAfter(prevNode); if (selectableNode != null) { - final nextComponent = documentLayoutResolver().getComponentByNodeId(selectableNode.id); + final documentLayout = documentLayoutResolver(); + final nextComponent = documentLayout.getComponentByNodeId(selectableNode.id); if (nextComponent != null) { - foundSelectableNode = nextComponent.isVisualSelectionSupported(); + foundSelectableNode = + documentLayout.isComponentVisible(selectableNode.id) && nextComponent.isVisualSelectionSupported(); } prevNode = selectableNode; } 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 d633a4a61a..21a8224252 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 @@ -1,11 +1,15 @@ +import 'dart:collection'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.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_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/node_grouping.dart'; import '_presenter.dart'; @@ -29,6 +33,7 @@ class SingleColumnDocumentLayout extends StatefulWidget { Key? key, required this.presenter, required this.componentBuilders, + this.groupBuilders = const [], this.onBuildScheduled, this.showDebugPaint = false, }) : super(key: key); @@ -45,6 +50,15 @@ class SingleColumnDocumentLayout extends StatefulWidget { /// that piece of content. final List componentBuilders; + /// {@template group_builders} + /// Builders that know how to group nodes together. + /// + /// Typically, components are organized vertically from top to bottom. A group + /// builder can be used to create a subtree with grouped components and add + /// features like group collapsing. + /// {@endtemplate} + final List groupBuilders; + /// Callback that's invoked whenever this widget schedules a build with /// `setState()`. /// @@ -59,10 +73,11 @@ class SingleColumnDocumentLayout extends StatefulWidget { final bool showDebugPaint; @override - State createState() => _SingleColumnDocumentLayoutState(); + State createState() => SingleColumnDocumentLayoutState(); } -class _SingleColumnDocumentLayoutState extends State implements DocumentLayout { +@visibleForTesting +class SingleColumnDocumentLayoutState extends State implements DocumentLayout { final Map _nodeIdsToComponentKeys = {}; final Map _componentKeysToNodeIds = {}; @@ -77,6 +92,21 @@ class _SingleColumnDocumentLayoutState extends State final GlobalKey _boxKey = GlobalKey(); BuildContext get boxContext => _boxKey.currentContext!; + /// The list of groups within this layout. + @visibleForTesting + List get groups => UnmodifiableListView(_groups); + final List _groups = []; + + /// Maps a node ID to the group that contains it. + /// + /// Includes the root node ID for each group and its child + /// node ID's. + final Map _nodeIdToGroup = {}; + + /// Holds the node ID of the root node of each group that + /// is currently collapsed. + final Set _collapsedGroups = {}; + @override void initState() { super.initState(); @@ -126,6 +156,43 @@ class _SingleColumnDocumentLayoutState extends State // Re-flow the whole layout. }); } + + if (changedComponents.isNotEmpty && widget.groupBuilders.isNotEmpty) { + // A node change might affect the grouping of nodes. For example, + // a paragraph node converted to a header should create a new group. + // Or, a header node converted to a paragraph should remove the group. + for (final nodeId in changedComponents) { + final nodeIndex = widget.presenter.viewModel.componentViewModels.indexWhere( + (viewModel) => viewModel.nodeId == nodeId, + ); + if (nodeIndex < 0) { + continue; + } + + final canNodeStartGroup = widget.groupBuilders.any( + (builder) => builder.canStartGroup( + nodeIndex: nodeIndex, + viewModels: widget.presenter.viewModel.componentViewModels, + ), + ); + + final group = _nodeIdToGroup[nodeId]; + final isAlreadyStartingGroup = group != null && group.rootNodeId == nodeId; + if (isAlreadyStartingGroup != canNodeStartGroup) { + // The component is either: + // - A header of a group, but it can't start a group anymore. + // - A regular component, but it can start a group now. + setState(() { + // Re-flow the layout to re-create the groups. + }); + } + } + } + } + + @override + bool isComponentVisible(String nodeId) { + return _isNodeVisible(nodeId); } @override @@ -393,6 +460,14 @@ class _SingleColumnDocumentLayoutState extends State continue; } + final nodeId = _componentKeysToNodeIds[componentKey]!; + if (!_isNodeVisible(nodeId)) { + // Collapsed components should be avoided at base or extent. + // They should only be selected when the surrounding components are selected. + editorLayoutLog.fine(' - node is not visible. Moving on.'); + continue; + } + final component = componentKey.currentState as DocumentComponent; // Unselectable components should be avoided at base or extent. @@ -554,7 +629,10 @@ class _SingleColumnDocumentLayoutState extends State GlobalKey? _findComponentClosestToOffset(Offset documentOffset) { GlobalKey? nearestComponentKey; double nearestDistance = double.infinity; - for (final componentKey in _nodeIdsToComponentKeys.values) { + for (final pair in _nodeIdsToComponentKeys.entries) { + final nodeId = pair.key; + final componentKey = pair.value; + if (componentKey.currentState is! DocumentComponent) { continue; } @@ -562,6 +640,11 @@ class _SingleColumnDocumentLayoutState extends State continue; } + if (!_isNodeVisible(nodeId)) { + // Ignore any nodes that aren't currently visible. + continue; + } + final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; if (_isOffsetInComponent(componentBox, documentOffset)) { return componentKey; @@ -707,6 +790,31 @@ class _SingleColumnDocumentLayoutState extends State widget.onBuildScheduled?.call(); } + /// Whether the node with the given [nodeId] is visible in the layout, i.e, it's not + /// inside a collapsed group. + bool _isNodeVisible(String nodeId) { + final group = _nodeIdToGroup[nodeId]; + if (group == null) { + // The node is not part of a group. It's always visible. + return true; + } + + // The root of the group is visible even when the group is collapsed. + final isVisibleInsideGroup = group.rootNodeId == nodeId || !_isGroupCollapsed(group); + return isVisibleInsideGroup && _isParentGroupVisible(group); + } + + bool _isGroupCollapsed(GroupItem group) => _collapsedGroups.contains(group.rootNodeId); + + bool _isParentGroupVisible(GroupItem group) { + final parentGroup = group.parent; + if (parentGroup == null) { + return true; + } + + return !_isGroupCollapsed(parentGroup) && _isParentGroupVisible(parentGroup); + } + @override Widget build(BuildContext context) { editorLayoutLog.fine("Building document layout"); @@ -735,33 +843,70 @@ class _SingleColumnDocumentLayoutState extends State _topToBottomComponentKeys.clear(); final viewModel = widget.presenter.viewModel; + final componentViewModels = viewModel.componentViewModels; editorLayoutLog.fine("Rendering layout view model: ${viewModel.hashCode}"); - for (final componentViewModel in viewModel.componentViewModels) { - final componentKey = _obtainComponentKeyForDocumentNode( - newComponentKeyMap: newComponentKeys, - nodeId: componentViewModel.nodeId, - ); - newNodeIds[componentKey] = componentViewModel.nodeId; - editorLayoutLog.finer('Node -> Key: ${componentViewModel.nodeId} -> $componentKey'); - - _topToBottomComponentKeys.add(componentKey); - - docComponents.add( - // Rebuilds whenever this particular component view model changes - // within the overall layout view model. - _PresenterComponentBuilder( - presenter: widget.presenter, - watchNode: componentViewModel.nodeId, - builder: (context, newComponentViewModel) { - // Converts the component view model into a widget. - return _Component( - componentBuilders: widget.componentBuilders, - componentKey: componentKey, - componentViewModel: newComponentViewModel, - ); - }, - ), + + final previouslyCollapsedGroups = _collapsedGroups.toSet(); + + _collapsedGroups.clear(); + _groups.clear(); + _nodeIdToGroup.clear(); + + // Build all doc components and create the groups, if any. + int currentNodeIndex = 0; + while (currentNodeIndex < componentViewModels.length) { + final componentViewModel = componentViewModels[currentNodeIndex]; + + _generateAndMapComponentKeyForDocumentNode( + componentViewModel: componentViewModel, + componentKeysToNodeIds: newNodeIds, + nodeIdsToComponentKeys: newComponentKeys, ); + + // Check if any group builders can start a group at this node. + bool didNodeStartedGroup = false; + for (final groupBuilder in widget.groupBuilders) { + final shouldStartGroup = groupBuilder.canStartGroup( + nodeIndex: currentNodeIndex, + viewModels: componentViewModels, + ); + + if (shouldStartGroup) { + // The current node is the start of a new group. For example, + // a header that groups all nodes below it until a new header of + // same level, or smaller, is found. Consume all nodes that can + // be grouped together. + final (widget, lastNodeIndexInGroup, groupInfo) = _makeGroup( + startingNodeIndex: currentNodeIndex, + groupBuilder: groupBuilder, + nodeIdsToComponentKeys: newComponentKeys, + componentKeysToNodeIds: newNodeIds, + allViewModels: componentViewModels, + previouslyCollapsedGroups: previouslyCollapsedGroups, + leaderLink: LeaderLink(), + ); + + didNodeStartedGroup = true; + docComponents.add(widget); + + // Advance to the next node after the group. + currentNodeIndex = lastNodeIndexInGroup + 1; + + // The group has been added. Ignore other group builders for this node. + break; + } + } + + if (!didNodeStartedGroup) { + // The current node is not part of a group. Add it as a regular component. + docComponents.add( + _buildComponent( + componentKey: newComponentKeys[componentViewModel.nodeId]!, + componentViewModel: componentViewModel, + ), + ); + currentNodeIndex += 1; + } } _nodeIdsToComponentKeys @@ -780,6 +925,198 @@ class _SingleColumnDocumentLayoutState extends State return docComponents; } + Widget _buildComponent({ + required GlobalKey componentKey, + required SingleColumnLayoutComponentViewModel componentViewModel, + LeaderLink? leaderLink, + }) { + // Rebuilds whenever this particular component view model changes + // within the overall layout view model. + return _PresenterComponentBuilder( + presenter: widget.presenter, + watchNode: componentViewModel.nodeId, + builder: (context, newComponentViewModel) { + // Converts the component view model into a widget. + return _Component( + componentBuilders: widget.componentBuilders, + componentKey: componentKey, + componentViewModel: newComponentViewModel, + leaderLink: leaderLink, + ); + }, + ); + } + + /// Creates a group of components starting from the given [startingNodeIndex]. + /// + /// A group contains a group header (the component at [startingNodeIndex]) and + /// any number of child components. + /// + /// Adds each item in [allViewModels] that can be grouped according to the given [groupBuilder]. + /// + /// The [leaderLink] is attached to the group header, so we can display other widgets near it. + /// + /// The [parent] must not be `null` if this group is inside another group. + (Widget component, int lastNodeIndexInGroup, GroupItem group) _makeGroup({ + required int startingNodeIndex, + required GroupBuilder groupBuilder, + required List allViewModels, + required LeaderLink leaderLink, + required Map nodeIdsToComponentKeys, + required Map componentKeysToNodeIds, + required Set previouslyCollapsedGroups, + GroupItem? parent, + }) { + // All viewmodels that are grouped together. + final groupedViewModels = []; + + // All components that are grouped together. + final groupedComponents = []; + + final groupHeader = allViewModels[startingNodeIndex]; + final groupInfo = GroupItem( + rootNodeId: groupHeader.nodeId, + parent: parent, + ); + if (parent != null) { + parent.add(groupInfo); + } + + // Restores the collapsed state of the group. + if (previouslyCollapsedGroups.contains(groupInfo.rootNodeId)) { + _collapsedGroups.add(groupInfo.rootNodeId); + } + + groupedViewModels.add(groupHeader); + groupedComponents.add( + _buildComponent( + componentKey: nodeIdsToComponentKeys[groupHeader.nodeId]!, + componentViewModel: groupHeader, + leaderLink: leaderLink, + ), + ); + + // Add all allowed child components to the group. + int currentNodeIndex = startingNodeIndex + 1; + while (currentNodeIndex < allViewModels.length) { + final childViewModel = allViewModels[currentNodeIndex]; + + final canAddToGroup = groupBuilder.canAddToGroup( + nodeIndex: currentNodeIndex, + allViewModels: allViewModels, + groupedComponents: UnmodifiableListView(groupedViewModels), + ); + if (!canAddToGroup) { + // The current node cannot be added to the group. The group ends + // before this node. + break; + } + + groupedViewModels.add(childViewModel); + + _generateAndMapComponentKeyForDocumentNode( + componentViewModel: childViewModel, + componentKeysToNodeIds: componentKeysToNodeIds, + nodeIdsToComponentKeys: nodeIdsToComponentKeys, + ); + + bool didChildNodeStartedGroup = false; + for (final childGroupBuilder in widget.groupBuilders) { + final shouldChildStartGroup = childGroupBuilder.canStartGroup( + nodeIndex: currentNodeIndex, + viewModels: allViewModels, + ); + if (shouldChildStartGroup) { + // The current child node can start another group. For example, + // it's a level two header inside a level one header. Let the child + // create its own group, and add the resulting widget as a child + // to this group. + final (widget, lastNodeIndexInChildGroup, childGroup) = _makeGroup( + startingNodeIndex: currentNodeIndex, + groupBuilder: childGroupBuilder, + nodeIdsToComponentKeys: nodeIdsToComponentKeys, + componentKeysToNodeIds: componentKeysToNodeIds, + allViewModels: allViewModels, + previouslyCollapsedGroups: previouslyCollapsedGroups, + leaderLink: LeaderLink(), + parent: groupInfo, + ); + + groupInfo.add(childGroup); + didChildNodeStartedGroup = true; + + // Add a subtree containing the child group to the current group. + groupedComponents.add(widget); + + // Move to the next node after the child group. + currentNodeIndex = lastNodeIndexInChildGroup + 1; + + // The child group has been added. Ignore other group builders for this node. + break; + } + } + if (!didChildNodeStartedGroup) { + // The current child node is not the start of a group. Add it as a regular component. + groupedComponents.add( + _buildComponent( + componentKey: nodeIdsToComponentKeys[childViewModel.nodeId]!, + componentViewModel: childViewModel, + ), + ); + + groupInfo.add(GroupItem( + rootNodeId: childViewModel.nodeId, + )); + + // Move to the next node. + currentNodeIndex += 1; + } + } + + _groups.add(groupInfo); + + // Map each node ID to the group which it belongs. + _nodeIdToGroup[groupInfo.rootNodeId] = groupInfo; + for (final child in groupInfo.children) { + _nodeIdToGroup[child.rootNodeId] = groupInfo; + } + + return ( + groupBuilder.build( + context, + headerContentLink: leaderLink, + groupInfo: groupInfo, + onCollapsedChanged: (bool collapsed) { + if (collapsed) { + _collapsedGroups.add(groupInfo.rootNodeId); + } else { + _collapsedGroups.remove(groupInfo.rootNodeId); + } + }, + children: groupedComponents, + ), + currentNodeIndex - 1, + groupInfo + ); + } + + /// Generate a new [GlobalKey] for the given [componentViewModel], if needed, and + /// creates mappings from the component key to the node ID and vice versa. + void _generateAndMapComponentKeyForDocumentNode({ + required SingleColumnLayoutComponentViewModel componentViewModel, + required Map componentKeysToNodeIds, + required Map nodeIdsToComponentKeys, + }) { + final componentKey = _obtainComponentKeyForDocumentNode( + newComponentKeyMap: nodeIdsToComponentKeys, + nodeId: componentViewModel.nodeId, + ); + componentKeysToNodeIds[componentKey] = componentViewModel.nodeId; + editorLayoutLog.finer('Node -> Key: ${componentViewModel.nodeId} -> $componentKey'); + + _topToBottomComponentKeys.add(componentKey); + } + /// Obtains a `GlobalKey` that should be attached to the component /// that represents the given [nodeId]. /// @@ -944,6 +1281,8 @@ class _Component extends StatelessWidget { required this.componentBuilders, required this.componentViewModel, required this.componentKey, + this.leaderLink, + // TODO(srawlins): `unused_element`, when reporting a parameter, is being // renamed to `unused_element_parameter`. For now, ignore each; when the SDK // constraint is >= 3.6.0, just ignore `unused_element_parameter`. @@ -965,6 +1304,12 @@ class _Component extends StatelessWidget { /// The visual configuration for the component that needs to be built. final SingleColumnLayoutComponentViewModel componentViewModel; + /// An optional [LeaderLink] to be attached to this component content. + /// + /// When non-null, a [Leader] widget is placed between the component's + /// padding and its content. + final LeaderLink? leaderLink; + /// Whether to add debug paint to the component. final bool showDebugPaint; @@ -977,6 +1322,12 @@ class _Component extends StatelessWidget { for (final componentBuilder in componentBuilders) { var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { + if (leaderLink != null) { + component = Leader( + link: leaderLink!, + child: component, + ); + } // TODO: we might need a SizeChangedNotifier here for the case where two components // change size exactly inversely component = ConstrainedBox( @@ -1005,3 +1356,77 @@ class _Component extends StatelessWidget { ); } } + +/// Information about a [DocumentNode] that is grouped together with other nodes. +/// +/// A [GroupItem] can be a leaf node, i.e., a regular node that doesn't start +/// a new group, for example, a regular paragraph, or it can be a group that contains +/// other nodes. For example, a level one header might have a level two header below it, +/// the level two header itself starts another group, but is part of the level one header's +/// group. +class GroupItem { + GroupItem({ + required this.rootNodeId, + this.parent, + }); + + /// The ID of the node that is the root of this group. + /// + /// This node appears immediately before its children in the document. + /// + /// If this is a leaf node, this is the ID of the node. + final String rootNodeId; + + /// The items that are grouped together with the node representer by [rootNodeId] + /// in the document. + /// + /// If any [children] is itself another group, only the root node of that group + /// appears in this list. + /// + /// If a [GroupItem] is a leaf node, this list will be empty. + List get children => UnmodifiableListView(_children); + final List _children = []; + + /// The parent group of this group, if this is a sub-group. + /// + /// For example, a level two header might have a level one header as its parent. + /// + /// If this is a top-level group, this is `null`. + final GroupItem? parent; + + /// Whether this group is a leaf node, i.e., it doesn't contain any child nodes. + bool get isLeaf => _children.isEmpty; + + /// Add [child] as a child of this group. + void add(GroupItem child) { + _children.add(child); + } + + /// The node IDs of all nodes that are a direct or indirect child of this group. + /// + /// For example, if this group contains a child group, the node IDs of each child + /// within the child group appear in this list. + List get allNodeIds { + final allNodeIds = [rootNodeId]; + for (final child in _children) { + allNodeIds.addAll(child.allNodeIds); + } + return allNodeIds; + } + + /// Whether the node with the given [nodeId] is a child of this group + /// or a child of one of its child groups. + bool contains(String nodeId) { + if (rootNodeId == nodeId) { + return true; + } + + for (final child in _children) { + if (child.contains(nodeId)) { + return true; + } + } + + return false; + } +} diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 5d4a487b28..0a76d4584f 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -28,6 +28,7 @@ import 'package:super_editor/src/infrastructure/documents/document_scroller.dart import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/src/infrastructure/node_grouping.dart'; import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; @@ -117,6 +118,7 @@ class SuperEditor extends StatefulWidget { Stylesheet? stylesheet, this.customStylePhases = const [], List? componentBuilders, + this.groupBuilders = const [], SelectionStyles? selectionStyle, this.selectionPolicies = const SuperEditorSelectionPolicies(), this.inputSource, @@ -299,6 +301,9 @@ class SuperEditor extends StatefulWidget { /// paragraph component, image component, horizontal rule component, etc. final List componentBuilders; + /// {@macro group_builders} + final List groupBuilders; + /// All actions that this editor takes in response to key /// events, e.g., text entry, newlines, character deletion, /// copy, paste, etc. @@ -675,6 +680,7 @@ class SuperEditorState extends State { scroller: _scroller, presenter: presenter, componentBuilders: widget.componentBuilders, + groupBuilders: widget.groupBuilders, shrinkWrap: widget.shrinkWrap, underlays: [ // Add all underlays from plugins. diff --git a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart index d83adaec09..fdba3cec56 100644 --- a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart +++ b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -5,8 +5,7 @@ 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/flutter/build_context.dart'; -import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; +import 'package:super_editor/src/infrastructure/node_grouping.dart'; /// A scaffold that combines pieces to create a scrolling single-column document, with /// gestures placed beneath the document. @@ -26,6 +25,7 @@ class DocumentScaffold extends StatefulWidget { required this.scroller, required this.presenter, required this.componentBuilders, + this.groupBuilders = const [], required this.shrinkWrap, this.underlays = const [], this.overlays = const [], @@ -71,6 +71,9 @@ class DocumentScaffold extends StatefulWidget { /// paragraph component, image component, horizontal rule component, etc. final List componentBuilders; + /// {@macro group_builders} + final List groupBuilders; + /// Layers that are displayed below the document layout, aligned /// with the location and size of the document layout. final List underlays; @@ -139,6 +142,7 @@ class _DocumentScaffoldState extends State { key: widget.documentLayoutKey, presenter: widget.presenter, componentBuilders: widget.componentBuilders, + groupBuilders: widget.groupBuilders, onBuildScheduled: onBuildScheduled, showDebugPaint: widget.debugPaint.layout, ), diff --git a/super_editor/lib/src/infrastructure/node_grouping.dart b/super_editor/lib/src/infrastructure/node_grouping.dart new file mode 100644 index 0000000000..a37c367364 --- /dev/null +++ b/super_editor/lib/src/infrastructure/node_grouping.dart @@ -0,0 +1,800 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.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'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; + +/// An object that knows how to group nodes in a document layout. +/// +/// A [GroupBuilder] is used to group nodes and create a sub-tree +/// containing all the grouped nodes. +/// +/// Each group has a header (the first node in the group) and one or more +/// child nodes. +/// +/// This object must not hold any internal state between calls, because creating +/// a group can be recursive. For example, a [GroupBuilder] that groups +/// content below a header can start a group when it encounters a +/// level one header, another one when it encounters a level two header, +/// and then resume the level one header. +abstract class GroupBuilder { + const GroupBuilder(); + + /// Whether the component at [nodeIndex] can start a new group. + /// + /// If this method returns `true`, a new group is created even + /// if [canAddToGroup] returns `false` for the node immediately + /// before the node at [nodeIndex]. + bool canStartGroup({ + required int nodeIndex, + required List viewModels, + }); + + /// Whether the component at [nodeIndex] can be added to the group + /// that contains [groupedComponents]. + /// + /// This method does not modify [groupedComponents]. + bool canAddToGroup({ + required int nodeIndex, + required List allViewModels, + required List groupedComponents, + }); + + /// Builds a widget that represents the group. + /// + /// The [headerContentLink] can used to position widgets near to the + /// header widget. Since document components can take all available width in + /// the layout, the [headerContentLink] is necessary to know where the + /// actual content starts. For example, text components usually have padding + /// around then. The [headerContentLink] must be attached to the widget inside + /// the padding. + /// + /// The [onCollapsedChanged] callback is called when the group is collapsed + /// or expanded. + /// + /// The [children] list contains all widgets inside the group, including + /// the header widget. + Widget build( + BuildContext context, { + required LeaderLink headerContentLink, + required GroupItem groupInfo, + required OnCollapseChanged onCollapsedChanged, + required List children, + }); +} + +/// A [GroupBuilder] that groups content below a header. +/// +/// This builder creates a group when it encounters a header node +/// that contains all nodes between the start of the group and +/// another header with smaller or equal level. +/// +/// Builds a toggleable [Widget] that allows collapsing and expanding +/// the group when tapping a button near the header. +class HeaderGroupBuilder implements GroupBuilder { + HeaderGroupBuilder({ + required this.editor, + this.buttonBuilder, + this.guidelineBuilder, + this.animateExpansion = true, + this.animationDuration = _defaultAnimationDuration, + this.animationCurve = Curves.easeInOut, + this.maxChildren, + }); + + final Editor editor; + + /// Builder for the button that toggles the group. + /// + /// No animations are applied to the button when [buttonBuilder] is provided, + /// i.e, apps that provide a custom button builder are responsible for its + /// animations. + final ToggleButtonBuilder? buttonBuilder; + + /// Builder for the guideline that is displayed below the button. + final WidgetBuilder? guidelineBuilder; + + /// Whether the expansion and collapse of the group should be animated. + /// + /// When `true`, the group will animate its expansion and collapse. When `false`, + /// the group will expand and collapse instantly. + final bool animateExpansion; + + /// Duration of the animation that expands and collapses the group. + /// + /// Has no effect if [animateExpansion] is `false`. + final Duration animationDuration; + + /// Curve of the animation that expands and collapses the group. + /// + /// Defaults to [Curves.easeInOut]. + final Curve animationCurve; + + /// Maximum number of children that can be grouped together. + /// + /// When a group reaches this limit, subsequent children will remain ungrouped. + final int? maxChildren; + + @override + bool canStartGroup({ + required int nodeIndex, + required List viewModels, + }) { + final currentViewModel = viewModels[nodeIndex]; + if (currentViewModel is! ParagraphComponentViewModel) { + // Only paragraphs can have the header attribution. + return false; + } + + if (_getHeaderLevel(currentViewModel.blockType) == null) { + // This paragraph is not a header. + return false; + } + + if (nodeIndex == viewModels.length - 1) { + // This is the last component in the layout. Only start a group + // if there is at least one child that can be grouped. + return false; + } + + return true; + } + + @override + bool canAddToGroup({ + required int nodeIndex, + required List allViewModels, + required List groupedComponents, + }) { + // +1 because the first component is the header. + if (maxChildren != null && groupedComponents.length >= maxChildren! + 1) { + return false; + } + + final header = groupedComponents.first; + final headerLevel = _getHeaderLevel((header as ParagraphComponentViewModel).blockType)!; + + final childViewModel = allViewModels[nodeIndex]; + if (childViewModel is ParagraphComponentViewModel) { + final childHeaderLevel = _getHeaderLevel(childViewModel.blockType); + if (childHeaderLevel != null && childHeaderLevel <= headerLevel) { + return false; + } + } + + return true; + } + + int? _getHeaderLevel(Attribution? blockType) => switch (blockType) { + header1Attribution => 1, + header2Attribution => 2, + header3Attribution => 3, + header4Attribution => 4, + header5Attribution => 5, + header6Attribution => 6, + _ => null, + }; + + @override + Widget build( + BuildContext context, { + required LeaderLink headerContentLink, + required GroupItem groupInfo, + required List children, + required OnCollapseChanged onCollapsedChanged, + }) { + return ToggleableGroup( + editor: editor, + groupInfo: groupInfo, + headerContentLink: headerContentLink, + onCollapsed: onCollapsedChanged, + buttonBuilder: buttonBuilder ?? defaultToggleButtonBuilder, + guidelineBuilder: guidelineBuilder ?? defaultGuidelineBuilder, + animateExpansion: animateExpansion, + animationDuration: animationDuration, + animationCurve: animationCurve, + header: children.first, + children: children.length > 1 // + ? children.skip(1).toList() + : [], + ); + } +} + +/// A [GroupBuilder] that groups list items. +/// +/// This builder creates a group when it encounters a list item node +/// that contains all list items between the start of the group and +/// another list item with smaller or equal level. +/// +/// Builds a toggleable [Widget] that allows collapsing and expanding +/// the group when tapping a button near the first list item. +class ListItemGroupBuilder implements GroupBuilder { + ListItemGroupBuilder({ + required this.editor, + this.buttonBuilder, + this.guidelineBuilder, + this.animateExpansion = true, + this.animationDuration = _defaultAnimationDuration, + this.animationCurve = Curves.easeInOut, + this.maxChildren, + }); + + final Editor editor; + + /// Builder for the button that toggles the group. + /// + /// No animations are applied to the button when [buttonBuilder] is provided, + /// i.e, apps that provide a custom button builder are responsible for its + /// animations. + final ToggleButtonBuilder? buttonBuilder; + + /// Builder for the guideline that is displayed below the button. + final WidgetBuilder? guidelineBuilder; + + /// Whether the expansion and collapse of the group should be animated. + /// + /// When `true`, the group will animate its expansion and collapse. When `false`, + /// the group will expand and collapse instantly. + final bool animateExpansion; + + /// Duration of the animation that expands and collapses the group. + /// + /// Has no effect if [animateExpansion] is `false`. + final Duration animationDuration; + + /// Curve of the animation that expands and collapses the group. + /// + /// Defaults to [Curves.easeInOut]. + final Curve animationCurve; + + /// Maximum number of children that can be grouped together. + /// + /// When a group reaches this limit, subsequent children will remain ungrouped. + final int? maxChildren; + + @override + bool canStartGroup({ + required int nodeIndex, + required List viewModels, + }) { + if (viewModels[nodeIndex] is! ListItemComponentViewModel) { + return false; + } + + if (nodeIndex == viewModels.length - 1) { + // This is the last component in the layout. Only start a group + // if there is at least one child that can be grouped. + return false; + } + + if (!canAddToGroup( + nodeIndex: nodeIndex + 1, + allViewModels: viewModels, + groupedComponents: [viewModels[nodeIndex]], + )) { + // This node can start a group, but the next node cannot be added to it. + // For example, the current node is a unordered list item and the node + // below it is an ordered list item. + return false; + } + + return true; + } + + @override + bool canAddToGroup( + {required int nodeIndex, + required List allViewModels, + required List groupedComponents}) { + // +1 because the first component is the header. + if (maxChildren != null && groupedComponents.length >= maxChildren! + 1) { + return false; + } + + final childViewModel = allViewModels[nodeIndex]; + if (childViewModel is! ListItemComponentViewModel) { + return false; + } + + final header = groupedComponents.first; + if (header.runtimeType != childViewModel.runtimeType) { + // Don't group ordered lists with unordered lists. + return false; + } + + final headerIndentLevel = (header as ListItemComponentViewModel).indent; + final childIndentLevel = (childViewModel).indent; + + return childIndentLevel > headerIndentLevel; + } + + @override + Widget build(BuildContext context, + {required LeaderLink headerContentLink, + required GroupItem groupInfo, + required OnCollapseChanged onCollapsedChanged, + required List children}) { + return ToggleableGroup( + editor: editor, + groupInfo: groupInfo, + headerContentLink: headerContentLink, + onCollapsed: onCollapsedChanged, + buttonBuilder: buttonBuilder ?? defaultToggleButtonBuilder, + guidelineBuilder: guidelineBuilder ?? defaultGuidelineBuilder, + animateExpansion: animateExpansion, + animationDuration: animationDuration, + animationCurve: animationCurve, + header: children.first, + children: children.length > 1 // + ? children.skip(1).toList() + : [], + ); + } +} + +/// A [Widget] that groups other widgets below a [header]. +/// +/// Displays a button and guideline near the content of the [header] widget, +/// that is positioned using the [headerContentLink]. +/// +/// The group can be collapsed and expanded by tapping a button near the +/// [header]. When collapsing, the [header] is still visible and the [children] +/// are hidden. +/// +/// Calls [onCollapsed] when the group is collapsed or expanded. +/// +/// When the group is collapsed, the selection is changed to avoid that the base +/// or extent of the selection is inside the collapsed group. +/// +/// Use [buttonBuilder] to customize the button that toggles the group. By default, +/// displays an arrow icon that rotates when the group is collapsed or expanded. +/// +/// Use [guidelineBuilder] to customize the guideline that is displayed below the +/// button. By default, displays a vertical divider. +class ToggleableGroup extends StatefulWidget { + const ToggleableGroup({ + super.key, + required this.headerContentLink, + required this.editor, + required this.groupInfo, + required this.onCollapsed, + this.buttonBuilder = defaultToggleButtonBuilder, + this.guidelineBuilder = defaultGuidelineBuilder, + this.animateExpansion = true, + this.animationDuration = _defaultAnimationDuration, + this.animationCurve = Curves.easeInOut, + required this.header, + required this.children, + }); + + final LeaderLink headerContentLink; + final Editor editor; + final GroupItem groupInfo; + final OnCollapseChanged onCollapsed; + final ToggleButtonBuilder buttonBuilder; + final WidgetBuilder guidelineBuilder; + final bool animateExpansion; + final Duration animationDuration; + final Curve animationCurve; + + final Widget header; + final List children; + + @override + State createState() => ToggleableGroupState(); +} + +@visibleForTesting +class ToggleableGroupState extends State with SingleTickerProviderStateMixin { + /// Animates the expansion and collapse of the group. + late final AnimationController _animationController; + late final Animation _animation; + + /// Whether the toggle button and guideline should be visible. + final _shouldDisplayButton = ValueNotifier(false); + + /// Whether the group is currently expanded, i.e, showing all children. + bool _isExpanded = true; + bool get isExpanded => _isExpanded; + + final _buttonKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + value: 1.0, + vsync: this, + duration: widget.animationDuration, + ); + _animation = CurvedAnimation( + parent: _animationController, + curve: widget.animationCurve, + ); + } + + @override + void didUpdateWidget(ToggleableGroup oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.animationDuration != oldWidget.animationDuration) { + _animationController.duration = widget.animationDuration; + } + + if (widget.animationCurve != oldWidget.animationCurve) { + _animation = CurvedAnimation( + parent: _animationController, + curve: widget.animationCurve, + ); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// Toggles the group between expanded and collapsed. + void _toggle() { + if (_isExpanded) { + //_animationController.reset(); + if (widget.animateExpansion) { + _animationController.reverse(); + } else { + _animationController.value = 0.0; + } + _adjustSelectionOnCollapsing(); + } else { + if (widget.animateExpansion) { + _animationController + ..reset() + ..forward(); + } else { + _animationController.value = 1.0; + } + } + + setState(() { + _isExpanded = !_isExpanded; + }); + + widget.onCollapsed(!_isExpanded); + } + + void _onMouseEnter() { + _shouldDisplayButton.value = true; + } + + void _onMouseExit() { + if (!_isExpanded) { + return; + } + _shouldDisplayButton.value = false; + } + + /// Adjusts the selection so that the base and extent are not inside the group. + void _adjustSelectionOnCollapsing() { + final selection = widget.editor.composer.selection; + if (selection == null) { + // There is no selection to adjust. + return; + } + + final headerNodeId = widget.groupInfo.rootNodeId; + final headerNode = widget.editor.document.getNodeById(headerNodeId)!; + + final isSelectionNormalized = selection.isNormalized(widget.editor.document); + + final isBaseInsideGroup = selection.base.nodeId != headerNodeId && widget.groupInfo.contains(selection.base.nodeId); + final isExtentInsideGroup = + selection.extent.nodeId != headerNodeId && widget.groupInfo.contains(selection.extent.nodeId); + + if (isBaseInsideGroup && isExtentInsideGroup) { + // The whole selection is inside the group. Move the selection + // to the end of the header node. + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: headerNodeId, + nodePosition: headerNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + + return; + } + + if (isBaseInsideGroup) { + if (isSelectionNormalized) { + // The selection starts inside this group and ends in a downstream node. + // Move the selection base to the beginning of the first node below the group. + final downstreamNodeIndex = _lastNodeIndex(widget.groupInfo) + 1; + final downstreamNode = widget.editor.document.getNodeAt(downstreamNodeIndex)!; + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: downstreamNode.id, + nodePosition: downstreamNode.beginningPosition, + ), + extent: selection.extent, + ), + SelectionChangeType.alteredContent, + SelectionReason.userInteraction, + ) + ]); + return; + } + + // The selection starts inside this group and ends in an upstream node. + // Move the selection base to the end of the header of the group. + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: headerNodeId, + nodePosition: headerNode.endPosition, + ), + extent: selection.extent, + ), + SelectionChangeType.alteredContent, + SelectionReason.userInteraction, + ) + ]); + + return; + } + + if (isExtentInsideGroup) { + if (isSelectionNormalized) { + // The selection starts at an upstream node end ends inside this group. + // Move the selection extent to the end of the header node. + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: selection.base, + extent: DocumentPosition( + nodeId: headerNode.id, + nodePosition: headerNode.endPosition, + ), + ), + SelectionChangeType.alteredContent, + SelectionReason.userInteraction, + ) + ]); + + return; + } + + // The selection starts at a downstream node end ends inside this group. + // Move the selection extent to the beginning of the first node below the group. + final downstreamNodeIndex = _lastNodeIndex(widget.groupInfo) + 1; + final downstreamNode = widget.editor.document.getNodeAt(downstreamNodeIndex)!; + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: selection.base, + extent: DocumentPosition( + nodeId: downstreamNode.id, + nodePosition: downstreamNode.beginningPosition, + ), + ), + SelectionChangeType.alteredContent, + SelectionReason.userInteraction, + ) + ]); + + return; + } + } + + /// The index of the last node within the group. + /// + /// If the last node also starts a group, returns the last index + /// of that group. + int _lastNodeIndex(GroupItem group) { + final lastChild = group.children.lastOrNull; + if (lastChild == null) { + return widget.editor.document.getNodeIndexById(group.rootNodeId); + } + + if (lastChild.isLeaf) { + return widget.editor.document.getNodeIndexById(lastChild.rootNodeId); + } + + return _lastNodeIndex(lastChild); + } + + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + widget.header, + _buildChildren(), + ], + ), + Positioned.fill( + child: _buildFadingFollower( + child: _buildButtonAndGuideline(), + ), + ), + ], + ); + } + + /// Builds the children of the group. + /// + /// Animates its growing/shrinking when the group is expanded/collapsed. + Widget _buildChildren() { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: _animation.value, + child: child, + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.children, + ), + ); + } + + /// Builds a widget that follows the header and fades in/out + /// when the mouse enters/exits the [child]. + Widget _buildFadingFollower({ + required Widget child, + }) { + return _buildFollower( + child: ValueListenableBuilder( + valueListenable: _shouldDisplayButton, + builder: (context, isVisible, child) { + return AnimatedOpacity( + opacity: isVisible ? 1.0 : 0.0, + duration: widget.animationDuration, + child: child!, + ); + }, + child: MouseRegion( + hitTestBehavior: HitTestBehavior.deferToChild, + onEnter: (e) => _onMouseEnter(), + onExit: (e) => _onMouseExit(), + child: _buildButtonAndGuideline(), + ), + ), + ); + } + + /// Builds a [child] positioned near the content of the header. + Widget _buildFollower({ + required Widget child, + }) { + return Follower.withAligner( + aligner: _ToggleButtonAligner(buttonKey: _buttonKey), + link: widget.headerContentLink, + child: child, + ); + } + + Widget _buildButtonAndGuideline() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildButton(), + Expanded( + child: _buildGuideline(), + ), + ], + ); + } + + /// Builds the button that toggles the group. + Widget _buildButton() { + return KeyedSubtree( + key: _buttonKey, + child: widget.buttonBuilder( + context, + _isExpanded, + _toggle, + ), + ); + } + + /// Builds the guideline that is displayed below the button. + /// + /// The guideline is only displayed when the group is expanded or + /// while the collapse animation is running. + Widget _buildGuideline() { + return ListenableBuilder( + listenable: _animationController, + builder: (context, child) { + return _isExpanded || _animationController.status == AnimationStatus.reverse + ? widget.guidelineBuilder(context) + : const SizedBox.shrink(); + }, + ); + } +} + +/// A button that rotates an arrow icon when the group is expanded or collapsed. +Widget defaultToggleButtonBuilder(BuildContext context, bool isExpanded, VoidCallback onPressed) { + return AnimatedRotation( + duration: _defaultAnimationDuration, + turns: isExpanded ? 0.25 : 0.0, + child: SizedBox( + height: 24, + width: 24, + child: Center( + child: IconButton( + icon: const Icon(Icons.arrow_right), + padding: EdgeInsets.zero, + iconSize: 24, + onPressed: onPressed, + ), + ), + ), + ); +} + +Widget defaultGuidelineBuilder(BuildContext context) { + return const Column( + children: [ + SizedBox(height: 2), + Expanded( + child: VerticalDivider(width: 4), + ), + ], + ); +} + +/// A [FollowerAligner] that centers the button vertically with the header. +/// +/// The regular aligner does not work because it uses the height of the whole +/// follower widget. Our follower contains both the button and the guideline, +/// and we want to center using only the button. +class _ToggleButtonAligner implements FollowerAligner { + _ToggleButtonAligner({ + required this.buttonKey, + }); + + final GlobalKey buttonKey; + + @override + FollowerAlignment align(Rect globalLeaderRect, Size followerSize) { + final buttonBox = buttonKey.currentContext?.findRenderObject() as RenderBox?; + final buttonHeight = buttonBox?.size.height ?? 0; + + return FollowerAlignment( + leaderAnchor: Alignment.centerLeft, + followerAnchor: Alignment.topRight, + followerOffset: Offset(-10, -buttonHeight / 2), + ); + } +} + +typedef OnCollapseChanged = void Function(bool isCollapsed); +typedef ToggleButtonBuilder = Widget Function(BuildContext context, bool isExpanded, VoidCallback onPressed); + +/// Duration of the animation that expands and collapses the group. +const _defaultAnimationDuration = Duration(milliseconds: 300); diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart index 7b43735363..98d2074a85 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart @@ -315,6 +315,52 @@ class SuperEditorInspector { return textLayout.getPositionAtEndOfLine(const TextPosition(offset: 0)).offset; } + /// Finds all [DocumentNode]s that are inside a group where the root is the + /// node with the given [rootNodeId]. + /// + /// All nodes, including the root, the child nodes and nodes inside child groups, are included. + /// + /// Returns an empty list if the root node does not start a group. + /// + /// {@macro supereditor_finder} + static List findAllNodesInGroup(String rootNodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder) as SingleColumnDocumentLayoutState; + final rootGroup = documentLayout.groups.firstWhereOrNull((group) => group.rootNodeId == rootNodeId); + if (rootGroup == null) { + return []; + } + + return rootGroup.allNodeIds; + } + + /// Finds the [DocumentNode] that is the header of the group where the + /// node with the given [nodeId] is. + /// + /// Returns `null` if the node is not in a group. + /// + /// {@macro supereditor_finder} + static String? findGroupHeaderNode(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder) as SingleColumnDocumentLayoutState; + return documentLayout.groups.firstWhereOrNull((group) => group.contains(nodeId))?.rootNodeId; + } + + static bool isGroupExpanded(String rootNodeId, [Finder? superEditorFinder]) { + final component = findWidgetForComponent(rootNodeId); + + final groupFinder = find.ancestor( + of: find.byWidget(component), + matching: find.byType(ToggleableGroup), + ); + + final groupElement = groupFinder.evaluate().singleOrNull as StatefulElement?; + if (groupElement == null) { + return false; + } + + final groupState = groupElement.state as ToggleableGroupState; + return groupState.isExpanded; + } + /// Finds the [DocumentLayout] that backs a [SuperEditor] in the widget tree. /// /// {@macro supereditor_finder} diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart index f7a0af1548..fc2346937d 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart @@ -275,6 +275,46 @@ extension SuperEditorRobot on WidgetTester { await endDocumentDragGesture(gesture); } + /// Simulates a user drag that begins at the [from] [DocumentPosition] + /// and ends at the [to] [DocumentPosition]. + /// + /// Provide a [pointerDeviceKind] to override the device kind used in the gesture. + /// If [pointerDeviceKind] is `null`, it defaults to [PointerDeviceKind.touch] + /// on mobile, and [PointerDeviceKind.mouse] on other platforms. + Future dragSelectDocumentFromPositionToPosition({ + required DocumentPosition from, + Alignment startAlignmentWithinPosition = Alignment.center, + required DocumentPosition to, + Alignment endAlignmentWithinPosition = Alignment.center, + PointerDeviceKind? pointerDeviceKind, + Finder? superEditorFinder, + }) async { + final deviceKind = pointerDeviceKind ?? + (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android + ? PointerDeviceKind.touch + : PointerDeviceKind.mouse); + + final gesture = await startDocumentDragFromPosition( + from: from, + startAlignmentWithinPosition: startAlignmentWithinPosition, + superEditorFinder: superEditorFinder, + deviceKind: deviceKind, + ); + + // Compute the global offset for the end position. + final documentLayout = _findDocumentLayout(superEditorFinder); + Rect dragEndRect = documentLayout.getRectForPosition(to)!; + final globalDocTopLeft = documentLayout.getGlobalOffsetFromDocumentOffset(Offset.zero); + dragEndRect = dragEndRect.translate(globalDocTopLeft.dx, globalDocTopLeft.dy); + final dragEndOffset = endAlignmentWithinPosition.withinRect(dragEndRect); + + // Move to the desired position. + await gesture.moveTo(dragEndOffset); + + // Release the drag and settle. + await endDocumentDragGesture(gesture); + } + /// Simulates a user drag that begins at the [from] [DocumentPosition] /// and returns the simulated gesture for further control. /// diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 39059cec3c..4572761040 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -92,6 +92,7 @@ export 'src/infrastructure/text_input.dart'; export 'src/infrastructure/popovers.dart'; export 'src/infrastructure/selectable_list.dart'; export 'src/infrastructure/actions.dart'; +export 'src/infrastructure/node_grouping.dart'; // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; diff --git a/super_editor/test/super_editor/supereditor_toggleable_headers_test.dart b/super_editor/test/super_editor/supereditor_toggleable_headers_test.dart new file mode 100644 index 0000000000..cd338c7ae3 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_toggleable_headers_test.dart @@ -0,0 +1,928 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../test_tools.dart'; + +Future main() async { + await loadAppFonts(); + group('SuperEditor > Toggleable Headers > ', () { + group('creates groups', () { + testWidgetsOnArbitraryDesktop('upon initialization', (tester) async { + await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('')), + ParagraphNode(id: '2', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '3', text: AttributedText('')), + ParagraphNode(id: '4', text: AttributedText('')), + ParagraphNode(id: '5', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '6', text: AttributedText('')), + ]), + ); + + // Ensure the groups were created. + final firstGroupNodes = SuperEditorInspector.findAllNodesInGroup('2'); + expect(firstGroupNodes, collectionEqualsTo(['2', '3', '4'])); + final secondGroupNodes = SuperEditorInspector.findAllNodesInGroup('5'); + expect(secondGroupNodes, collectionEqualsTo(['5', '6'])); + }); + + testWidgetsOnArbitraryDesktop('upon node insertion at the end', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText()), + ]), + ); + + // Insert the paragraph already as a header. + editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: '1', + newNode: ParagraphNode( + id: '2', + text: AttributedText(), + metadata: {NodeMetadata.blockType: header1Attribution}, + ), + ), + ]); + await tester.pump(); + + // Place the caret at the end of the header and add a new node + // so we will have a group with two nodes. + await tester.placeCaretInParagraph('2', 0); + await tester.pressEnter(); + + // Ensure the group was created. + final allNodes = SuperEditorInspector.findAllNodesInGroup('2'); + expect(allNodes, collectionEqualsTo(['2', editor.document.last.id])); + }); + + testWidgetsOnArbitraryDesktop('upon node insertion between nodes', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText()), + ParagraphNode(id: '3', text: AttributedText()), + ]), + ); + + // Insert the paragraph already as a header. + editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: '1', + newNode: ParagraphNode( + id: '2', + text: AttributedText(), + metadata: {NodeMetadata.blockType: header1Attribution}, + ), + ), + ]); + await tester.pump(); + + // Ensure the group was created. + final allNodes = SuperEditorInspector.findAllNodesInGroup('2'); + expect(allNodes, collectionEqualsTo(['2', '3'])); + }); + + testWidgetsOnArbitraryDesktop('when converting a node to a header', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('')), + ParagraphNode(id: '2', text: AttributedText('')), + ParagraphNode(id: '3', text: AttributedText('')), + ]), + ); + + // Ensure there are no groups. + expect(SuperEditorInspector.findAllNodesInGroup('1'), []); + expect(SuperEditorInspector.findAllNodesInGroup('2'), []); + expect(SuperEditorInspector.findAllNodesInGroup('3'), []); + + // Place the caret at the beginning of the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Type "# " to convert the paragraph to a header. + await tester.typeImeText('# '); + + // Ensure the paragraph was converted to a header. + expect(editor.document.first.getMetadataValue('blockType'), header1Attribution); + + // Ensure the nodes were grouped. + expect(SuperEditorInspector.findAllNodesInGroup('1'), collectionEqualsTo(['1', '2', '3'])); + }); + }); + + group('removes groups', () { + testWidgetsOnArbitraryDesktop('when deleting the root node', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('')), + ParagraphNode(id: '2', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '3', text: AttributedText('')), + ParagraphNode(id: '4', text: AttributedText('')), + ParagraphNode(id: '5', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ]), + ); + + // Ensure the nodes are grouped. + expect(SuperEditorInspector.findGroupHeaderNode('3'), '2'); + expect(SuperEditorInspector.findGroupHeaderNode('4'), '2'); + + // Delete the root of the group. + // + // Use a delete request to ensure we are deleting, because pressing + // backspace will first convert the header into a regular paragraph. + editor.execute([DeleteNodeRequest(nodeId: '2')]); + await tester.pump(); + + // Ensure the nodes are not grouped anymore. + expect(SuperEditorInspector.findGroupHeaderNode('3'), isNull); + expect(SuperEditorInspector.findGroupHeaderNode('4'), isNull); + }); + + testWidgetsOnArbitraryDesktop('when converting the root node to a regular paragraph', (tester) async { + await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('')), + ParagraphNode(id: '2', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '3', text: AttributedText('')), + ParagraphNode(id: '4', text: AttributedText('')), + ParagraphNode(id: '5', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ]), + ); + + // Ensure the nodes are grouped. + expect(SuperEditorInspector.findGroupHeaderNode('3'), '2'); + expect(SuperEditorInspector.findGroupHeaderNode('4'), '2'); + + // Place the caret at the beginning of the header. + await tester.placeCaretInParagraph('2', 0); + + // Press backspace to convert the header to a regular paragraph. + await tester.pressBackspace(); + + // Ensure the nodes are not grouped anymore. + expect(SuperEditorInspector.findGroupHeaderNode('3'), isNull); + expect(SuperEditorInspector.findGroupHeaderNode('4'), isNull); + }); + }); + + group('splits groups', () { + testWidgetsOnArbitraryDesktop('when inserting a header of same level', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '2', text: AttributedText('')), + ParagraphNode(id: '3', text: AttributedText('')), + ParagraphNode(id: '4', text: AttributedText('')), + ]), + ); + + // Ensure all nodes are in the same group. + expect(SuperEditorInspector.findAllNodesInGroup('1'), collectionEqualsTo(['1', '2', '3', '4'])); + + // Place the caret at the end of the second node. + await tester.placeCaretInParagraph('2', 0); + + // Create a new node and convert it to a header. + await tester.pressEnter(); + await tester.typeImeText('# '); + + // Ensure the nodes were split into two groups. + expect(SuperEditorInspector.findAllNodesInGroup('1'), collectionEqualsTo(['1', '2'])); + final newNodeId = editor.document.getNodeAt(2)!.id; + expect(SuperEditorInspector.findAllNodesInGroup(newNodeId), collectionEqualsTo([newNodeId, '3', '4'])); + }); + }); + + group('preserves expanded state', () { + testWidgetsOnArbitraryDesktop('when adding an item to the group', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '2', text: AttributedText('')), + ParagraphNode(id: '3', text: AttributedText('')), + ]), + ); + + // Ensure the group is expanded uppon initialization. + expect(SuperEditorInspector.isGroupExpanded('1'), isTrue); + + // Place the caret at the last child of the header. + await tester.placeCaretInParagraph('3', 0); + + // Press enter to add a new node to the group. + await tester.pressEnter(); + + // Ensure the group is still expanded. + expect(SuperEditorInspector.isGroupExpanded('1'), isTrue); + + // Ensure the node was added to the group. + expect( + SuperEditorInspector.findAllNodesInGroup('1'), + collectionEqualsTo(['1', '2', '3', editor.document.last.id]), + ); + }); + }); + + group('preserves collapsed state', () { + testWidgetsOnArbitraryDesktop('when removing items from the group', (tester) async { + final editor = await _buildToggleableTestApp( + tester, + document: MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText(''), metadata: {NodeMetadata.blockType: header1Attribution}), + ParagraphNode(id: '2', text: AttributedText('')), + ParagraphNode(id: '3', text: AttributedText('')), + ]), + ); + + // Ensure the group is expanded uppon initialization. + expect(SuperEditorInspector.isGroupExpanded('1'), isTrue); + + // Collapse the group. + await pressToggleButton(tester, '1'); + + // Ensure the group was collapsed. + expect(SuperEditorInspector.isGroupExpanded('1'), isFalse); + + // Remove the last child of the group. Since the group is collapsed, + // we cannot delete just the child node with an user interaction. + editor.execute([DeleteNodeRequest(nodeId: '3')]); + await tester.pump(); + + // Ensure the group is still collapsed. + expect(SuperEditorInspector.isGroupExpanded('1'), isFalse); + }); + }); + + group('selection', () { + group('does not select a collapsed component', () { + testWidgetsOnArbitraryDesktop('when placing caret', (tester) async { + await _buildToggleableTestApp(tester); + + // Store the offset of the level two header. This component will be hidden + // when the group collapses. + final hiddenComponentOffset = SuperEditorInspector.findComponentOffset('2', Alignment.center); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Tap where the level two header was positioned before being hidden. + await tester.tapAt(hiddenComponentOffset); + await tester.pump(kDoubleTapTimeout); + + // Ensure the caret was placed at the downstream level one header. + final selection = SuperEditorInspector.findDocumentSelection(); + expect(selection, isNotNull); + expect(selection!.isCollapsed, isTrue); + expect(selection.extent.nodeId, equals('4')); + }); + + testWidgetsOnArbitraryDesktop('at extent (dragging downstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Store the offset of the first child component. This component will be hidden + // when the group collapses. + final offset = SuperEditorInspector.findComponentOffset('1.1', Alignment.center); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Start dragging from the beginning of the first header. + final testGesture = await tester.startDocumentDragFromPosition( + from: const DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Drag down to the position where the hidden component was. + await testGesture.moveTo(offset); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure only the first header is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('at extent (dragging upstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Store the offset of the first child component. This component will be hidden + // when the group collapses. + final hiddenComponentOffset = SuperEditorInspector.findComponentOffset('1.1', Alignment.center); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Start dragging from the end of the last level one header. + final testGesture = await tester.startDocumentDragFromPosition( + from: const DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ); + + // Drag up to the position where the hidden component was. + await testGesture.moveTo(hiddenComponentOffset); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure only the last header is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('at base (dragging downstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Store the offset of the first child component. This component will be hidden + // when the group collapses. + final hiddenComponentOffset = SuperEditorInspector.findComponentOffset('1.1', Alignment.topLeft); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Start dragging from the position where the hidden component was. + final testGesture = await tester.startGesture( + hiddenComponentOffset, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + // Move a tiny amount to start the pan gesture. + await testGesture.moveBy(const Offset(2, 2)); + await tester.pump(); + + // Drag to the end of the last header. + await testGesture.moveTo( + SuperEditorInspector.findComponentOffset('4', Alignment.bottomRight), + ); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure only the last header is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('at base (dragging upstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Store the offset of last child component. This component will be hidden + // when the group collapses. + final hiddenComponentOffset = SuperEditorInspector.findComponentOffset('4.1', Alignment.bottomRight); + + // Collapse the last header. + await pressToggleButton(tester, '4'); + + // Start dragging from the position where the hidden component was. + final testGesture = await tester.startGesture( + hiddenComponentOffset, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + // Move a tiny amount to start the pan gesture. + await testGesture.moveBy(const Offset(2, 2)); + await tester.pump(); + + // Drag to beginning end of the last header. + await testGesture.moveTo( + SuperEditorInspector.findComponentOffset('4', Alignment.topLeft), + ); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure only the last header is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + }); + + group('selects a collapsed component', () { + testWidgetsOnArbitraryDesktop('when selecting surrounding components (dragging downstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Start dragging from the beginning of the document. + final testGesture = await tester.startDocumentDragFromPosition( + from: const DocumentPosition( + nodeId: '0', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Drag down to the end of the last header. + await testGesture.moveTo(SuperEditorInspector.findComponentOffset('4', Alignment.bottomRight)); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure the whole range was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '0', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('when selecting surrounding components (dragging upstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Start dragging from the end of the last header. + final testGesture = await tester.startDocumentDragFromPosition( + from: const DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ); + + // Drag up to the beginning of the document. + await testGesture.moveTo(SuperEditorInspector.findComponentOffset('0', Alignment.topLeft)); + await tester.pump(); + await testGesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure the whole range was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + extent: DocumentPosition( + nodeId: '0', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + }); + }); + + group('keyboard navigation', () { + group('skips collapsed components', () { + testWidgetsOnDesktop('when moving down with ARROW DOWN', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Place the caret at the beginning of the first header. + await tester.placeCaretInParagraph('1', 0); + + // Move the caret down. + await tester.pressDownArrow(); + + // Ensure the caret skipped the collapsed components and + // was placed at the beginning of the next header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when moving down with ARROW RIGHT at the end of a node', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Place the caret at the end of the first header. + await tester.placeCaretInParagraph('1', 8); + + // Move the caret down. + await tester.pressRightArrow(); + + // Ensure the caret skipped the collapsed components and + // was placed at the beginning of the next header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when moving up with ARROW UP', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Place the caret at the beginning of the last header. + await tester.placeCaretInParagraph('4', 0); + + // Move the caret up. + await tester.pressUpArrow(); + + // Ensure the caret skipped the collapsed components and + // was placed at the beginning of the first header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when moving up with ARROW LEFT at the beginning of a node', (tester) async { + await _buildToggleableTestApp(tester); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Place the caret at the beginning of the last header. + await tester.placeCaretInParagraph('4', 0); + + // Move the caret up. + await tester.pressLeftArrow(); + + // Ensure the caret skipped the collapsed components and + // was placed at the beginning of the first header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ), + ); + }); + }); + }); + + group('is adjusted when toggled > ', () { + testWidgetsOnArbitraryDesktop('when extent is collapsed (downstream)', (tester) async { + await _buildToggleableTestApp(tester); + + await tester.dragSelectDocumentFromPositionToPosition( + from: const DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + to: const DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 8), + ), + ); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Ensure the extent moved to the end of the header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('when extent is collapsed (upstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Select the text from the end of the last header to the beginning of the last + // child node which will be collapsed. + await tester.dragSelectDocumentFromPositionToPosition( + from: const DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + to: const DocumentPosition( + nodeId: '3.2', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Ensure the extent moved to the beginning of the last header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('when base is collapsed (downstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Select from the first child of the first header to the end of the last header. + await tester.dragSelectDocumentFromPositionToPosition( + from: const DocumentPosition( + nodeId: '1.1', + nodePosition: TextNodePosition(offset: 0), + ), + to: const DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Ensure the base moved to the beginning of the last header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 16), + ), + ), + ), + ); + }); + + testWidgetsOnArbitraryDesktop('when base is collapsed (upstream)', (tester) async { + await _buildToggleableTestApp(tester); + + // Select from the end of the first child of the first header to the beginning of + // the first regular paragraph. + await tester.dragSelectDocumentFromPositionToPosition( + from: const DocumentPosition( + nodeId: '1.1', + nodePosition: TextNodePosition(offset: 9), + ), + to: const DocumentPosition( + nodeId: '0', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Collapse the first header. + await pressToggleButton(tester, '1'); + + // Ensure the base moved to the end of the group header. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + extent: DocumentPosition( + nodeId: '0', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + }); + }); +} + +Future _buildToggleableTestApp( + WidgetTester tester, { + MutableDocument? document, +}) async { + final effectiveDocument = document ?? + MutableDocument(nodes: [ + ParagraphNode( + id: '0', + text: AttributedText('Regular Paragraph'), + ), + ParagraphNode( + id: '1', + text: AttributedText('Header 1'), + metadata: {NodeMetadata.blockType: header1Attribution}, + ), + ParagraphNode( + id: '1.1', + text: AttributedText('Some text'), + ), + ParagraphNode( + id: '2', + text: AttributedText('Header 2'), + metadata: {NodeMetadata.blockType: header2Attribution}, + ), + ParagraphNode( + id: '3', + text: AttributedText('Header 3'), + metadata: {NodeMetadata.blockType: header3Attribution}, + ), + ParagraphNode( + id: '3.1', + text: AttributedText('Another text'), + ), + ParagraphNode( + id: '3.2', + text: AttributedText('Another text'), + ), + ParagraphNode( + id: '4', + text: AttributedText('Another Header 1'), + metadata: {NodeMetadata.blockType: header1Attribution}, + ), + ParagraphNode( + id: '4.1', + text: AttributedText('Another text'), + ), + ]); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: effectiveDocument, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SuperEditor( + editor: editor, + groupBuilders: [ + HeaderGroupBuilder( + editor: editor, + ) + ], + ), + ), + ), + ), + ); + + return editor; +} + +Future pressToggleButton( + WidgetTester tester, + String nodeId, [ + Finder? superEditorFinder, +]) async { + final documentLayout = SuperEditorInspector.findDocumentLayout(superEditorFinder); + + final componentState = documentLayout.getComponentByNodeId(nodeId) as State; + + // Find the group where the component is located. + final groupFinder = find.ancestor( + of: find.byKey(componentState.widget.key!), + matching: find.byType(ToggleableGroup), + ); + + // Find the toggle button inside the group. + // + // For some reason, when there are nested groups, the last one + // is the toggle for the group. Probably because the parent group + // places the toggle button above all the other groups. + final toggleButtonFinder = find + .descendant( + of: groupFinder, + matching: find.byIcon(Icons.arrow_right), + ) + .last; + + // Simulate the tap manually because we need to hover over the + // button to make it visible. + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + + // Hover over the toggle button to make it visible. + await tester.sendEventToBinding( + testPointer.hover(tester.getCenter(toggleButtonFinder)), + ); + await tester.pumpAndSettle(); + + // Press the button. + await tester.sendEventToBinding( + testPointer.down(tester.getCenter(toggleButtonFinder)), + ); + await tester.pumpAndSettle(); + + // Release the button. + await tester.sendEventToBinding(testPointer.up()); + await tester.pumpAndSettle(); +} diff --git a/super_editor/test/test_tools.dart b/super_editor/test/test_tools.dart index 1512e90072..47abbe9a1c 100644 --- a/super_editor/test/test_tools.dart +++ b/super_editor/test/test_tools.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/infrastructure/links.dart'; @@ -130,3 +131,44 @@ class EquivalentSelectionMatcher extends Matcher { return null; } } + +/// A [Matcher] that compares two lists for deep equality. +Matcher collectionEqualsTo(List expectedList) => CollectionEqualityMatcher(expectedList); + +/// A [Matcher] that compares two lists for deep equality. +class CollectionEqualityMatcher extends Matcher { + const CollectionEqualityMatcher(this.expected); + + final List expected; + + @override + Description describe(Description description) { + return description.add("given the list contains the same elements in the same order as the expected list"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return const DeepCollectionEquality().equals(target, expected); + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + if (target is! List) { + mismatchDescription.add('The target is not a list'); + return mismatchDescription; + } + + final expectedList = '[${expected.join(', ')}]'; + final targetList = '[${target.join(', ')}]'; + + mismatchDescription + .add('The lists don\'t have the same elements in the same order\nExpected: $expectedList\nActual: $targetList'); + + return mismatchDescription; + } +}