diff --git a/super_editor/clones/quill/lib/editor/code_component.dart b/super_editor/clones/quill/lib/editor/code_component.dart index 1a878fb141..e44c93edf6 100644 --- a/super_editor/clones/quill/lib/editor/code_component.dart +++ b/super_editor/clones/quill/lib/editor/code_component.dart @@ -5,7 +5,11 @@ class FeatherCodeComponentBuilder implements ComponentBuilder { const FeatherCodeComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/example/lib/demos/components/demo_text_with_hint.dart b/super_editor/example/lib/demos/components/demo_text_with_hint.dart index 527590a19d..8878769436 100644 --- a/super_editor/example/lib/demos/components/demo_text_with_hint.dart +++ b/super_editor/example/lib/demos/components/demo_text_with_hint.dart @@ -146,7 +146,11 @@ class HeaderWithHintComponentBuilder implements ComponentBuilder { const HeaderWithHintComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This component builder can work with the standard paragraph view model. // We'll defer to the standard paragraph component builder to create it. return null; diff --git a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart index 64983b8095..55fcecef29 100644 --- a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart +++ b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart @@ -71,7 +71,11 @@ class UnselectableHrComponentBuilder implements ComponentBuilder { const UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/example/lib/demos/demo_animated_task_height.dart b/super_editor/example/lib/demos/demo_animated_task_height.dart index bb06d9671b..76a6a1d59b 100644 --- a/super_editor/example/lib/demos/demo_animated_task_height.dart +++ b/super_editor/example/lib/demos/demo_animated_task_height.dart @@ -72,7 +72,11 @@ class AnimatedTaskComponentBuilder implements ComponentBuilder { const AnimatedTaskComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart b/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart new file mode 100644 index 0000000000..68cb70b2e3 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart @@ -0,0 +1,301 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class CompositeNodesDemo extends StatefulWidget { + const CompositeNodesDemo({super.key}); + + @override + State createState() => _CompositeNodesDemoState(); +} + +class _CompositeNodesDemoState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createInitialDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: darkModeStyles, + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + componentBuilders: [ + _BannerComponentBuilder(), + ...defaultComponentBuilders, + ], + ), + ); + } +} + +MutableDocument _createInitialDocument() { + return MutableDocument( + nodes: [ + ParagraphNode(id: "1.1", text: AttributedText("Paragraph before the first level of embedding.")), + CompositeDocumentNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + CompositeDocumentNode("3", [ + ParagraphNode(id: "3.1", text: AttributedText("This paragraph is in the 3rd level of document.")), + ]), + ParagraphNode(id: "2.3", text: AttributedText("Paragraph after the second level of embedding.")), + ]), + ParagraphNode(id: "1.3", text: AttributedText("Paragraph after the first level of embedding.")), + ], + ); +} + +class _BannerComponentBuilder implements ComponentBuilder { + _BannerComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + if (node is! CompositeDocumentNode) { + return null; + } + + print("Creating a composite view model (${node.id}) with ${node.nodeCount} child nodes"); + final childViewModels = []; + for (final childNode in node.nodes) { + print(" - Creating view model for child node: $childNode"); + SingleColumnLayoutComponentViewModel? viewModel; + for (final builder in componentBuilders) { + viewModel = builder.createViewModel(document, childNode, componentBuilders); + if (viewModel != null) { + break; + } + } + + print(" - view model: $viewModel"); + if (viewModel != null) { + childViewModels.add(viewModel); + } + } + + return CompositeViewModel( + nodeId: node.id, + node: node, + childViewModels: childViewModels, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! CompositeViewModel) { + return null; + } + print( + "Composite builder - createComponent() - with ${componentViewModel.childViewModels.length} child view models"); + + final childComponentIds = []; + final childComponents = []; + for (final childViewModel in componentViewModel.childViewModels) { + print("Creating component for child view model: $childViewModel"); + final childContext = SingleColumnDocumentComponentContext( + context: componentContext.context, + componentKey: GlobalKey(), + componentBuilders: componentContext.componentBuilders, + ); + Widget? component; + for (final builder in componentContext.componentBuilders) { + component = builder.createComponent(childContext, childViewModel); + if (component != null) { + break; + } + } + + print(" - component: $component"); + if (component != null) { + childComponentIds.add(childViewModel.nodeId); + childComponents.add(component); + } + } + + return _BannerComponent( + key: componentContext.componentKey, + node: componentViewModel.node, + childComponentIds: childComponentIds, + childComponents: childComponents, + ); + } +} + +class _BannerComponent extends StatefulWidget { + const _BannerComponent({ + super.key, + required this.node, + required this.childComponentIds, + required this.childComponents, + }); + + final CompositeDocumentNode node; + final List childComponentIds; + final List childComponents; + + @override + State<_BannerComponent> createState() => _BannerComponentState(); +} + +class _BannerComponentState extends State<_BannerComponent> with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return widget.node.beginningPosition; + } + + @override + NodePosition getBeginningPositionNearX(double x) { + // TODO: implement getBeginningPositionNearX + throw UnimplementedError(); + } + + @override + NodePosition getEndPosition() { + return widget.node.endPosition; + } + + @override + NodePosition getEndPositionNearX(double x) { + // TODO: implement getEndPositionNearX + throw UnimplementedError(); + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + return widget.node.computeSelection(base: nodePosition, extent: nodePosition); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + // TODO: implement getDesiredCursorAtOffset + throw UnimplementedError(); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + // TODO: implement getEdgeForPosition + throw UnimplementedError(); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + // TODO: implement getOffsetForPosition + throw UnimplementedError(); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + print("Looking for position in composite component at local offset: $localOffset"); + final compositeBox = context.findRenderObject() as RenderBox; + for (int i = 0; i < widget.childComponents.length; i += 1) { + final childComponent = widget.childComponents[i]; + print("Component widget: ${childComponent} - key: ${childComponent.key}"); + final componentKey = childComponent.key as GlobalKey; + final component = componentKey.currentState as DocumentComponent; + final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; + final componentLocalOffset = componentBox.localToGlobal(Offset.zero, ancestor: compositeBox); + final offsetInComponent = localOffset - componentLocalOffset; + final positionInComponent = component.getPositionAtOffset(offsetInComponent); + if (positionInComponent != null) { + print("Found position in component! - ${widget.childComponentIds[i]} - $positionInComponent"); + return CompositeNodePosition( + compositeNodeId: widget.node.id, + childNodeId: widget.childComponentIds[i], + childNodePosition: positionInComponent, + ); + } + } + + return null; + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + // TODO: implement getRectForPosition + throw UnimplementedError(); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + // TODO: implement getRectForSelection + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + // TODO: implement getSelectionBetween + throw UnimplementedError(); + } + + @override + NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { + // TODO: implement getSelectionInRange + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionOfEverything() { + // TODO: implement getSelectionOfEverything + throw UnimplementedError(); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + // TODO: implement movePositionDown + throw UnimplementedError(); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionLeft + throw UnimplementedError(); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionRight + throw UnimplementedError(); + } + + @override + NodePosition? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey), + color: Colors.grey.withOpacity(0.1), + ), + padding: const EdgeInsets.all(24), + child: Column( + children: widget.childComponents, + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart index 73c75a9250..355b6fb022 100644 --- a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart +++ b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart @@ -181,8 +181,13 @@ class SpellingErrorParagraphComponentBuilder implements ComponentBuilder { final UnderlineStyle underlineStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - final viewModel = ParagraphComponentBuilder().createViewModel(document, node) as ParagraphComponentViewModel?; + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + final viewModel = + ParagraphComponentBuilder().createViewModel(document, node, componentBuilders) as ParagraphComponentViewModel?; if (viewModel == null) { return null; } diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 8b0a94dd58..075fbd676c 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -15,6 +15,7 @@ import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; import 'package:example/demos/in_the_lab/feature_action_tags.dart'; +import 'package:example/demos/in_the_lab/feature_composite_nodes.dart'; import 'package:example/demos/in_the_lab/feature_ios_native_context_menu.dart'; import 'package:example/demos/in_the_lab/feature_pattern_tags.dart'; import 'package:example/demos/in_the_lab/feature_stable_tags.dart'; @@ -333,6 +334,13 @@ final _menu = <_MenuGroup>[ return const NativeIosContextMenuFeatureDemo(); }, ), + _MenuItem( + icon: Icons.account_tree, + title: 'Embedded Components', + pageBuilder: (context) { + return const CompositeNodesDemo(); + }, + ), ], ), _MenuGroup( diff --git a/super_editor/example_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart index b7908122cf..9c3f2c8531 100644 --- a/super_editor/example_perf/lib/demos/rebuild_demo.dart +++ b/super_editor/example_perf/lib/demos/rebuild_demo.dart @@ -74,7 +74,11 @@ class AnimatedTaskComponentBuilder implements ComponentBuilder { const AnimatedTaskComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 5c4f932fb6..bc5d47cfff 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -481,6 +481,346 @@ extension InspectNodeAffinity on DocumentNode { } } +/// The path to a [DocumentNode] within a [Document]. +/// +/// In the average case, the [NodePath] is effectively the same as a node's +/// ID. However, some nodes are [CompositeDocumentNode]s, which have a hierarchy. +/// For a composite node, the node path includes every node ID in the composite +/// hierarchy. +class NodePath { + factory NodePath.forDocumentPosition(DocumentPosition position) { + var nodePosition = position.nodePosition; + if (nodePosition is CompositeNodePosition) { + // This node position is a hierarchy of nodes. Encode all nodes + // along that path into the node path. + final nodeIds = [position.nodeId]; + + while (nodePosition is CompositeNodePosition) { + nodeIds.add(nodePosition.childNodeId); + nodePosition = nodePosition.childNodePosition; + } + + return NodePath(nodeIds); + } + + // This position refers to a singular node. Build a node path that only + // contains this node's ID. + return NodePath([position.nodeId]); + } + + factory NodePath.forNode(String nodeId) { + return NodePath([nodeId]); + } + + const NodePath(this.nodeIds); + + final List nodeIds; + + NodePath addSubPath(String nodeId) => NodePath([...nodeIds, nodeId]); + + @override + String toString() => "[NodePath] - ${nodeIds.join(" > ")}"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodePath && + runtimeType == other.runtimeType && + const DeepCollectionEquality().equals(nodeIds, other.nodeIds); + + @override + int get hashCode => const ListEquality().hash(nodeIds); +} + +/// A [DocumentNode] that contains other [DocumentNode]s in a hierarchy. +/// +/// [CompositeDocumentNode]s can contain more [CompositeDocumentNode]s. There's no +/// logical restriction on the depth of this hierarchy. However, the effect of a multi-level +/// hierarchy depends on the document layout and components that are used within a +/// given editor. +class CompositeDocumentNode extends DocumentNode { + CompositeDocumentNode(this.id, this._nodes) + : assert(_nodes.isNotEmpty, "CompositeDocumentNode's must contain at least 1 inner node."); + + @override + final String id; + + Iterable get nodes => List.from(_nodes); + final List _nodes; + + int get nodeCount => _nodes.length; + + @override + NodePosition get beginningPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.first.id, + childNodePosition: _nodes.first.beginningPosition, + ); + + @override + NodePosition get endPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.last.id, + childNodePosition: _nodes.last.endPosition, + ); + + @override + NodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { + if (position1 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position1 but received a ${position1.runtimeType}'); + } + if (position2 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position2 but received a ${position2.runtimeType}'); + } + + if (position1.compositeNodeId != id) { + throw Exception( + "Expected position1 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position1.compositeNodeId}"); + } + if (position2.compositeNodeId != id) { + throw Exception( + "Expected position2 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position2.compositeNodeId}"); + } + + final position1NodeIndex = _findNodeIndexById(position1.childNodeId); + if (position1NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position1.childNodeId}"); + } + + final position2NodeIndex = _findNodeIndexById(position2.childNodeId); + if (position2NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position2.childNodeId}"); + } + + if (position1NodeIndex <= position2NodeIndex) { + return position1; + } else { + return position2; + } + } + + @override + NodePosition selectDownstreamPosition(NodePosition position1, NodePosition position2) { + if (position1 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position1 but received a ${position1.runtimeType}'); + } + if (position2 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position2 but received a ${position2.runtimeType}'); + } + + if (position1.compositeNodeId != id) { + throw Exception( + "Expected position1 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position1.compositeNodeId}"); + } + if (position2.compositeNodeId != id) { + throw Exception( + "Expected position2 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position2.compositeNodeId}"); + } + + final position1NodeIndex = _findNodeIndexById(position1.childNodeId); + if (position1NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position1.childNodeId}"); + } + + final position2NodeIndex = _findNodeIndexById(position2.childNodeId); + if (position2NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position2.childNodeId}"); + } + + if (position1NodeIndex < position2NodeIndex) { + return position2; + } else { + return position1; + } + } + + @override + CompositeNodeSelection computeSelection({required NodePosition base, required NodePosition extent}) { + if (base is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for base but received a ${base.runtimeType}'); + } + if (extent is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for extent but received a ${extent.runtimeType}'); + } + + return CompositeNodeSelection(base: base, extent: extent); + } + + @override + bool containsPosition(Object position) { + // Composite nodes don't have a node position type. This query doesn't apply. + throw UnimplementedError(); + } + + int? _findNodeIndexById(String childNodeId) { + for (int i = 0; i < _nodes.length; i += 1) { + if (_nodes[i].id == childNodeId) { + return i; + } + } + + return null; + } + + @override + String? copyContent(NodeSelection selection) { + if (selection is! CompositeNodeSelection) { + return null; + } + + if (selection.base.compositeNodeId != id) { + return null; + } + + final baseNodeIndex = _findNodeIndexById(selection.base.childNodeId); + if (baseNodeIndex == null) { + return null; + } + + final extentNodeIndex = _findNodeIndexById(selection.extent.childNodeId); + if (extentNodeIndex == null) { + return null; + } + + if (baseNodeIndex == extentNodeIndex) { + // The selection sits entirely within a single node. Copy partial content + // from that node. + final childNode = _nodes[extentNodeIndex]; + final childSelection = childNode.computeSelection( + base: selection.base.childNodePosition, + extent: selection.extent.childNodePosition, + ); + return childNode.copyContent(childSelection); + } + + // The selection spans some number of nodes. Collate content from all of those nodes. + final buffer = StringBuffer(); + if (baseNodeIndex < extentNodeIndex) { + // The selection is in natural order. Grab content starting at the base + // position, all the way to the extent position. + final startNode = _nodes[baseNodeIndex]; + buffer.writeln(startNode.copyContent( + startNode.computeSelection(base: selection.base.childNodePosition, extent: startNode.endPosition), + )); + + for (int i = baseNodeIndex + 1; i < extentNodeIndex; i += 1) { + final node = _nodes[i]; + buffer.writeln( + node.copyContent( + node.computeSelection(base: node.beginningPosition, extent: node.endPosition), + ), + ); + } + + final endNode = _nodes[extentNodeIndex]; + buffer.write(endNode.copyContent( + endNode.computeSelection(base: endNode.beginningPosition, extent: selection.extent.childNodePosition), + )); + } else { + // The selection is in reverse order. Grab content starting at the extent + // position, all the way to the base position. + final startNode = _nodes[extentNodeIndex]; + buffer.writeln(startNode.copyContent( + startNode.computeSelection(base: selection.extent.childNodePosition, extent: startNode.endPosition), + )); + + for (int i = extentNodeIndex + 1; i < baseNodeIndex; i += 1) { + final node = _nodes[i]; + buffer.writeln( + node.copyContent( + node.computeSelection(base: node.beginningPosition, extent: node.endPosition), + ), + ); + } + + final endNode = _nodes[baseNodeIndex]; + buffer.write(endNode.copyContent( + endNode.computeSelection(base: endNode.beginningPosition, extent: selection.base.childNodePosition), + )); + } + + return buffer.toString(); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return copy(); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return copy(); + } + + DocumentNode copy() { + return CompositeDocumentNode(id, List.from(_nodes)); + } + + @override + String toString() => "[CompositeNode] - $_nodes"; +} + +/// A selection within a single [CompositeDocumentNode]. +class CompositeNodeSelection implements NodeSelection { + const CompositeNodeSelection({ + required this.base, + required this.extent, + }); + + final CompositeNodePosition base; + final CompositeNodePosition extent; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositeNodeSelection && + runtimeType == other.runtimeType && + base == other.base && + extent == other.extent; + + @override + int get hashCode => base.hashCode ^ extent.hashCode; +} + +/// A [NodePosition] for a [CompositeDocumentNode], which is a node that contains +/// other nodes in a node hierarchy. +class CompositeNodePosition implements NodePosition { + const CompositeNodePosition({ + required this.compositeNodeId, + required this.childNodeId, + required this.childNodePosition, + }); + + final String compositeNodeId; + final String childNodeId; + final NodePosition childNodePosition; + + @override + bool isEquivalentTo(NodePosition other) { + if (other is! CompositeNodePosition) { + return false; + } + + if (compositeNodeId != other.compositeNodeId || childNodeId != other.childNodeId) { + return false; + } + + return childNodePosition.isEquivalentTo(other.childNodePosition); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositeNodePosition && + runtimeType == other.runtimeType && + compositeNodeId == other.compositeNodeId && + childNodeId == other.childNodeId && + childNodePosition == other.childNodePosition; + + @override + int get hashCode => compositeNodeId.hashCode ^ childNodeId.hashCode ^ childNodePosition.hashCode; +} + /// Marker interface for a selection within a [DocumentNode]. abstract class NodeSelection { // marker interface diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 7a176f2795..e3fdd1d21d 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -25,7 +25,11 @@ class BlockquoteComponentBuilder implements ComponentBuilder { const BlockquoteComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } 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 102be8c8c9..c99970d2c6 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -439,6 +439,7 @@ class CommonEditorOperations { throw Exception( 'Could not find next component to move the selection horizontally. Next node ID: ${nextNode.id}'); } + print("Next component: $nextComponent, beginning position: ${nextComponent.getBeginningPosition()}"); newExtentNodePosition = nextComponent.getBeginningPosition(); } diff --git a/super_editor/lib/src/default_editor/composite_component.dart b/super_editor/lib/src/default_editor/composite_component.dart new file mode 100644 index 0000000000..426809c10f --- /dev/null +++ b/super_editor/lib/src/default_editor/composite_component.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; + +class CompositeComponentBuilder implements ComponentBuilder { + const CompositeComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + if (node is! CompositeDocumentNode) { + return null; + } + + print("Creating a composite view model (${node.id}) with ${node.nodeCount} child nodes"); + final childViewModels = []; + for (final childNode in node.nodes) { + print(" - Creating view model for child node: $childNode"); + SingleColumnLayoutComponentViewModel? viewModel; + for (final builder in componentBuilders) { + viewModel = builder.createViewModel(document, childNode, componentBuilders); + if (viewModel != null) { + break; + } + } + + print(" - view model: $viewModel"); + if (viewModel != null) { + childViewModels.add(viewModel); + } + } + + return CompositeViewModel( + nodeId: node.id, + node: node, + childViewModels: childViewModels, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! CompositeViewModel) { + return null; + } + print( + "Composite builder - createComponent() - with ${componentViewModel.childViewModels.length} child view models"); + + final childComponents = []; + for (final childViewModel in componentViewModel.childViewModels) { + print("Creating component for child view model: $childViewModel"); + final childContext = SingleColumnDocumentComponentContext( + context: componentContext.context, + componentKey: GlobalKey(), + componentBuilders: componentContext.componentBuilders, + ); + Widget? component; + for (final builder in componentContext.componentBuilders) { + component = builder.createComponent(childContext, childViewModel); + if (component != null) { + break; + } + } + + print(" - component: $component"); + if (component != null) { + childComponents.add(component); + } + } + + return CompositeComponent( + key: componentContext.componentKey, + node: componentViewModel.node, + childComponents: childComponents, + ); + } +} + +class CompositeViewModel extends SingleColumnLayoutComponentViewModel { + CompositeViewModel({ + required super.nodeId, + required this.node, + super.maxWidth, + super.padding = EdgeInsets.zero, + required this.childViewModels, + }); + + final CompositeDocumentNode node; + final List childViewModels; + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + + // Forward styles to our children. + for (final child in childViewModels) { + child.applyStyles(styles); + } + } + + @override + SingleColumnLayoutComponentViewModel copy() { + return CompositeViewModel( + nodeId: nodeId, + node: node, + maxWidth: maxWidth, + padding: padding, + childViewModels: List.from(childViewModels), + ); + } +} + +class CompositeComponent extends StatefulWidget { + const CompositeComponent({ + super.key, + required this.node, + required this.childComponents, + }); + + final CompositeDocumentNode node; + final List childComponents; + + @override + State createState() => _CompositeComponentState(); +} + +class _CompositeComponentState extends State with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return widget.node.beginningPosition; + } + + @override + NodePosition getBeginningPositionNearX(double x) { + // TODO: implement getBeginningPositionNearX + throw UnimplementedError(); + } + + @override + NodePosition getEndPosition() { + return widget.node.endPosition; + } + + @override + NodePosition getEndPositionNearX(double x) { + // TODO: implement getEndPositionNearX + throw UnimplementedError(); + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + return widget.node.computeSelection(base: nodePosition, extent: nodePosition); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + // TODO: implement getDesiredCursorAtOffset + throw UnimplementedError(); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + // TODO: implement getEdgeForPosition + throw UnimplementedError(); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + // TODO: implement getOffsetForPosition + throw UnimplementedError(); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + // TODO: implement getPositionAtOffset + throw UnimplementedError(); + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + // TODO: implement getRectForPosition + throw UnimplementedError(); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + // TODO: implement getRectForSelection + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + // TODO: implement getSelectionBetween + throw UnimplementedError(); + } + + @override + NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { + // TODO: implement getSelectionInRange + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionOfEverything() { + // TODO: implement getSelectionOfEverything + throw UnimplementedError(); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + // TODO: implement movePositionDown + throw UnimplementedError(); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionLeft + throw UnimplementedError(); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionRight + throw UnimplementedError(); + } + + @override + NodePosition? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey), + color: Colors.grey.withOpacity(0.1), + ), + padding: const EdgeInsets.all(24), + child: Column( + children: widget.childComponents, + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart index f2b1f23b91..420cafede1 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart @@ -464,13 +464,15 @@ class TextDeltasDocumentEditor { final bottomImeToDocTextRange = TextRange(start: imeNewlineIndex + 1, end: newImeValue.text.length); // Update mapping from Document nodes to IME ranges. - _serializedDoc.docTextNodesToImeRanges[originNode.id] = topImeToDocTextRange; - _serializedDoc.docTextNodesToImeRanges[newNode.id] = bottomImeToDocTextRange; + // FIXME: Don't assume that every node is a top-level node + _serializedDoc.docTextNodesToImeRanges[NodePath.forNode(originNode.id)] = topImeToDocTextRange; + _serializedDoc.docTextNodesToImeRanges[NodePath.forNode(newNode.id)] = bottomImeToDocTextRange; // Remove old mapping from IME TextRange to Document node. - late final MapEntry oldImeToDoc; + late final MapEntry oldImeToDoc; for (final entry in _serializedDoc.imeRangesToDocTextNodes.entries) { - if (entry.value != originNode.id) { + // FIXME: Don't assume that every node is a top-level node + if (entry.value != NodePath.forNode(originNode.id)) { continue; } @@ -480,8 +482,9 @@ class TextDeltasDocumentEditor { _serializedDoc.imeRangesToDocTextNodes.remove(oldImeToDoc.key); // Update and add mapping from IME TextRanges to Document nodes. - _serializedDoc.imeRangesToDocTextNodes[topImeToDocTextRange] = originNode.id; - _serializedDoc.imeRangesToDocTextNodes[bottomImeToDocTextRange] = newNode.id; + // FIXME: Don't assume that every node is a top-level node + _serializedDoc.imeRangesToDocTextNodes[topImeToDocTextRange] = NodePath.forNode(originNode.id); + _serializedDoc.imeRangesToDocTextNodes[bottomImeToDocTextRange] = NodePath.forNode(newNode.id); } DocumentSelection? _calculateNewDocumentSelection(TextEditingDelta delta) { diff --git a/super_editor/lib/src/default_editor/document_ime/document_serialization.dart b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart index b7824a7baa..df0aaced1c 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_serialization.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_selection.dart'; @@ -37,15 +38,37 @@ class DocumentImeSerializer { final Document _doc; DocumentSelection selection; DocumentRange? composingRegion; - final imeRangesToDocTextNodes = {}; - final docTextNodesToImeRanges = {}; + final imeRangesToDocTextNodes = {}; + final docTextNodesToImeRanges = {}; final selectedNodes = []; late String imeText; final PrependedCharacterPolicy _prependedCharacterPolicy; String _prependedPlaceholder = ''; + // TextNode(1) - Hello world + // TextNode(2) - Paragraph 2 + // ImageNode(3) + // TextNode(4) - YOLO + // CompositeNode(5) + // TextNode(6) - Inner paragraph + // ListItemNode(7) - Item 1 + // ListItemNode(8) - Item 2 + // ListItemNode(9) - Item 3 + // TextNode(10) - Final paragraph + + // CompositeNode(5) + // TextNode(6) - Inner para|graph + // + // CompositeNodePosition + // - node ID: "5" + // - child node ID: "6" + // - child node position: TextNodePosition(offset: 10) + // + // .Inner Paragraph + void _serialize() { editorImeLog.fine("Creating an IME model from document, selection, and composing region"); + print("Serializing document to send to the IME"); final buffer = StringBuffer(); int characterCount = 0; @@ -65,6 +88,8 @@ class DocumentImeSerializer { _prependedPlaceholder = ''; } + print("Selection: $selection"); + print(""); selectedNodes.clear(); selectedNodes.addAll(_doc.getNodesInContentOrder(selection)); for (int i = 0; i < selectedNodes.length; i += 1) { @@ -78,14 +103,23 @@ class DocumentImeSerializer { characterCount += 1; } - final node = selectedNodes[i]; + var node = selectedNodes[i]; + final nodePath = NodePath.forNode(node.id); + print("Serializing node for IME: $node"); + if (node is CompositeDocumentNode) { + final serializedCharacterCount = _serializeCompositeNode(NodePath.forNode(node.id), node, buffer); + characterCount += serializedCharacterCount; + + continue; + } + if (node is! TextNode) { buffer.write('~'); characterCount += 1; final imeRange = TextRange(start: characterCount - 1, end: characterCount); - imeRangesToDocTextNodes[imeRange] = node.id; - docTextNodesToImeRanges[node.id] = imeRange; + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; continue; } @@ -94,8 +128,8 @@ class DocumentImeSerializer { // so that we can easily convert between the two, when requested. final imeRange = TextRange(start: characterCount, end: characterCount + node.text.length); editorImeLog.finer("IME range $imeRange -> text node content '${node.text.toPlainText()}'"); - imeRangesToDocTextNodes[imeRange] = node.id; - docTextNodesToImeRanges[node.id] = imeRange; + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; // Concatenate this node's text with the previous nodes. buffer.write(node.text.toPlainText()); @@ -106,6 +140,49 @@ class DocumentImeSerializer { editorImeLog.fine("IME serialization:\n'$imeText'"); } + int _serializeCompositeNode(NodePath nodePath, CompositeDocumentNode node, StringBuffer buffer) { + int characterCount = 0; + for (final innerNode in node.nodes) { + final innerNodePath = nodePath.addSubPath(innerNode.id); + if (innerNode is CompositeDocumentNode) { + characterCount += _serializeCompositeNode(innerNodePath, innerNode, buffer); + continue; + } + + characterCount += _serializeNonCompositeNode(innerNodePath, node, buffer, characterCount); + + if (innerNode != node.nodes.last) { + buffer.write('\n'); + characterCount += 1; + } + } + + return characterCount; + } + + int _serializeNonCompositeNode(NodePath nodePath, DocumentNode node, StringBuffer buffer, int characterCount) { + if (node is! TextNode) { + buffer.write('~'); + + final imeRange = TextRange(start: characterCount - 1, end: characterCount); + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; + + return 1; + } + + // Cache mappings between the IME text range and the document position + // so that we can easily convert between the two, when requested. + final imeRange = TextRange(start: characterCount, end: characterCount + node.text.length); + editorImeLog.finer("IME range $imeRange -> text node content '${node.text.text}'"); + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; + + // Concatenate this node's text with the previous nodes. + buffer.write(node.text.text); + return node.text.length; + } + bool _shouldPrependPlaceholder() { if (_prependedCharacterPolicy == PrependedCharacterPolicy.include) { // The client explicitly requested prepended characters. This is @@ -265,28 +342,54 @@ class DocumentImeSerializer { DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { for (final range in imeRangesToDocTextNodes.keys) { if (range.start <= imePosition.offset && imePosition.offset <= range.end) { - final node = _doc.getNodeById(imeRangesToDocTextNodes[range]!)!; + final nodePath = imeRangesToDocTextNodes[range]!; + final node = _doc.getNodeById(nodePath.nodeIds.last)!; + late NodePosition contentNodePosition; if (node is TextNode) { - return DocumentPosition( - nodeId: imeRangesToDocTextNodes[range]!, - nodePosition: TextNodePosition(offset: imePosition.offset - range.start), - ); + contentNodePosition = TextNodePosition(offset: imePosition.offset - range.start); + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: TextNodePosition(offset: imePosition.offset - range.start), + // ); } else { if (imePosition.offset <= range.start) { // Return a position at the start of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.beginningPosition, - ); + contentNodePosition = node.beginningPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.beginningPosition, + // ); } else { // Return a position at the end of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.endPosition, - ); + contentNodePosition = node.endPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.endPosition, + // ); } } + + if (nodePath.nodeIds.length == 1) { + // This is a single node - not a composite node. Return it as-is. + return DocumentPosition( + nodeId: node.id, + nodePosition: contentNodePosition, + ); + } + + NodePosition compositeNodePosition = contentNodePosition; + for (int i = nodePath.nodeIds.length - 2; i >= 0; i -= 1) { + compositeNodePosition = CompositeNodePosition( + compositeNodeId: nodePath.nodeIds[i], + childNodeId: nodePath.nodeIds[i + 1], + childNodePosition: compositeNodePosition, + ); + } + return DocumentPosition( + nodeId: nodePath.nodeIds.first, + nodePosition: compositeNodePosition, + ); } } @@ -303,13 +406,17 @@ class DocumentImeSerializer { editorImeLog.shout("IME Ranges to text nodes:"); for (final entry in imeRangesToDocTextNodes.entries) { editorImeLog.shout(" - IME range: ${entry.key} -> Text node: ${entry.value}"); - editorImeLog.shout(" ^ node content: '${(_doc.getNodeById(entry.value) as TextNode).text.toPlainText()}'"); + editorImeLog.shout(" ^ node content: '${_getTextNodeAtNodePath(entry.value).text.toPlainText()}'"); } editorImeLog.shout("-----------------------------------------------------------"); throw Exception( "Couldn't map an IME position to a document position. \nTextEditingValue: '$imeText'\nIME position: $imePosition"); } + TextNode _getTextNodeAtNodePath(NodePath path) { + return _doc.getNodeById(path.nodeIds.last) as TextNode; + } + TextSelection documentToImeSelection(DocumentSelection docSelection) { editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); final selectionAffinity = _doc.getAffinityForSelection(docSelection); @@ -346,9 +453,15 @@ class DocumentImeSerializer { TextPosition _documentToImePosition(DocumentPosition docPosition) { editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); - final imeRange = docTextNodesToImeRanges[docPosition.nodeId]; + // FIXME: don't assume top-level node + final nodePath = NodePath.forNode(docPosition.nodeId); + final imeRange = docTextNodesToImeRanges[nodePath]; if (imeRange == null) { - throw Exception("No such document position in the IME content: $docPosition"); + print("Available node paths in mapping:"); + for (final entry in docTextNodesToImeRanges.entries) { + print(" - ${entry.key}"); + } + throw Exception("No such node path in the IME content: $nodePath"); } final nodePosition = docPosition.nodePosition; @@ -371,6 +484,14 @@ class DocumentImeSerializer { return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); } + if (nodePosition is CompositeNodePosition) { + final innerDocumentPosition = + DocumentPosition(nodeId: nodePosition.childNodeId, nodePosition: nodePosition.childNodePosition); + + // Recursive call to create the IME text position for the content within the composite node. + return _documentToImePosition(innerDocumentPosition); + } + throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); } diff --git a/super_editor/lib/src/default_editor/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 37ad0e68e1..3db9aaf4fc 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -68,7 +68,11 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { const HorizontalRuleComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! HorizontalRuleNode) { return null; } diff --git a/super_editor/lib/src/default_editor/image.dart b/super_editor/lib/src/default_editor/image.dart index c35aeed030..da2a4b0f04 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -108,7 +108,11 @@ class ImageComponentBuilder implements ComponentBuilder { const ImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ImageNode) { return null; } 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..f6f3ea8d51 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 @@ -970,13 +970,18 @@ class _Component extends StatelessWidget { @override Widget build(BuildContext context) { + print("Layout build()"); final componentContext = SingleColumnDocumentComponentContext( context: context, componentKey: componentKey, + componentBuilders: List.unmodifiable(componentBuilders), ); + print("Building component for view model: $componentViewModel"); for (final componentBuilder in componentBuilders) { + print(" - Trying to create component with build: $componentBuilder"); var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { + print(" - This builder gave us a component"); // TODO: we might need a SizeChangedNotifier here for the case where two components // change size exactly inversely component = ConstrainedBox( diff --git a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart index 90827514fe..0b2eef54cf 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart @@ -13,6 +13,7 @@ class SingleColumnDocumentComponentContext { const SingleColumnDocumentComponentContext({ required this.context, required this.componentKey, + required this.componentBuilders, }); /// The [BuildContext] for the parent of the [DocumentComponent] @@ -25,6 +26,10 @@ class SingleColumnDocumentComponentContext { /// The [componentKey] is used by the [DocumentLayout] to query for /// node-specific information, like node positions and selections. final GlobalKey componentKey; + + /// All registered [ComponentBuilder]s for the document layout, which can + /// be used to create components within components. + final List componentBuilders; } /// Produces [SingleColumnLayoutViewModel]s to be displayed by a @@ -167,7 +172,7 @@ class SingleColumnLayoutPresenter { for (final node in _document) { SingleColumnLayoutComponentViewModel? viewModel; for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, node); + viewModel = builder.createViewModel(_document, node, _componentBuilders); if (viewModel != null) { break; } @@ -365,7 +370,11 @@ typedef ViewModelChangeCallback = void Function({ abstract class ComponentBuilder { /// Produces a [SingleColumnLayoutComponentViewModel] with default styles for the given /// [node], or returns `null` if this builder doesn't apply to the given node. - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node); + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ); /// Creates a visual component that renders the given [viewModel], /// or returns `null` if this builder doesn't apply to the given [viewModel]. diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index f663143c21..73a6493809 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -151,7 +151,11 @@ class ListItemComponentBuilder implements ComponentBuilder { const ListItemComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ListItemNode) { return null; } diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index dfadf61136..e74f586528 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -105,7 +105,11 @@ class ParagraphComponentBuilder implements ComponentBuilder { const ParagraphComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index d7457bc714..a7499b1431 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -14,6 +14,7 @@ import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/composite_component.dart'; import 'package:super_editor/src/default_editor/debug_visualization.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; @@ -1349,6 +1350,7 @@ const defaultComponentBuilders = [ ListItemComponentBuilder(), ImageComponentBuilder(), HorizontalRuleComponentBuilder(), + CompositeComponentBuilder(), ]; /// Default list of document overlays that are displayed on top of the document diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index daf3116b1b..acbd076551 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -158,7 +158,11 @@ class TaskComponentBuilder implements ComponentBuilder { final Editor _editor; @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } diff --git a/super_editor/lib/src/default_editor/unknown_component.dart b/super_editor/lib/src/default_editor/unknown_component.dart index 6a90defe3f..8644c89ac2 100644 --- a/super_editor/lib/src/default_editor/unknown_component.dart +++ b/super_editor/lib/src/default_editor/unknown_component.dart @@ -8,7 +8,11 @@ class UnknownComponentBuilder implements ComponentBuilder { const UnknownComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return _UnkownViewModel( nodeId: node.id, padding: EdgeInsets.zero, diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 1aa9219638..915d88809b 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -21,6 +21,7 @@ export 'src/default_editor/blockquote.dart'; export 'src/default_editor/box_component.dart'; export 'src/default_editor/common_editor_operations.dart'; export 'src/default_editor/composer/composer_reactions.dart'; +export 'src/default_editor/composite_component.dart'; export 'src/default_editor/debug_visualization.dart'; export 'src/default_editor/default_document_editor.dart'; export 'src/default_editor/default_document_editor_reactions.dart'; diff --git a/super_editor/test/super_editor/deletme.png b/super_editor/test/super_editor/deletme.png new file mode 100644 index 0000000000..6c50ffce87 Binary files /dev/null and b/super_editor/test/super_editor/deletme.png differ diff --git a/super_editor/test/super_editor/infrastructure/document_test.dart b/super_editor/test/super_editor/infrastructure/document_test.dart index ca5aa18576..c1d0fbfcf2 100644 --- a/super_editor/test/super_editor/infrastructure/document_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_test.dart @@ -3,6 +3,18 @@ import 'package:super_editor/super_editor.dart'; void main() { group("Document", () { + group("node paths >", () { + test("equality", () { + expect(NodePath.forNode("1"), equals(NodePath.forNode("1"))); + expect(NodePath.forNode("1"), isNot(equals(NodePath.forNode("2")))); + + final map = { + NodePath.forNode("1"): "Hello", + }; + expect(map[NodePath.forNode("1")], "Hello"); + }); + }); + group("nodes", () { group("equality", () { test("equivalent TextNodes are equal", () { diff --git a/super_editor/test/super_editor/super_editor_embedded_documents_test.dart b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart new file mode 100644 index 0000000000..10a9fd191b --- /dev/null +++ b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart @@ -0,0 +1,37 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; + +import 'supereditor_test_tools.dart'; + +void main() { + group("SuperEditor embedded documents >", () { + testWidgetsOnAllPlatforms("displays embedded documents", (tester) async { + tester.view.physicalSize = const Size(600, 600); + addTearDown(() { + tester.view.resetPhysicalSize(); + }); + + await tester + .createDocument() + .withCustomContent(MutableDocument(nodes: [ + ParagraphNode(id: "1.1", text: AttributedText("Paragraph before the first level of embedding.")), + CompositeDocumentNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + CompositeDocumentNode("3", [ + ParagraphNode(id: "3.1", text: AttributedText("This paragraph is in the 3rd level of document.")), + ]), + ParagraphNode(id: "2.3", text: AttributedText("Paragraph after the second level of embedding.")), + ]), + ParagraphNode(id: "1.3", text: AttributedText("Paragraph after the first level of embedding.")), + ])) + .pump(); + + await expectLater(find.byType(MaterialApp), matchesGoldenFile("deletme.png")); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_component_selection_test.dart b/super_editor/test/super_editor/supereditor_component_selection_test.dart index 31cc7db618..fc98e2e9de 100644 --- a/super_editor/test/super_editor/supereditor_component_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_component_selection_test.dart @@ -597,7 +597,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; @@ -756,7 +760,11 @@ class _ButtonComponentBuilder implements ComponentBuilder { const _ButtonComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! _ButtonNode) { return null; } diff --git a/super_editor/test/super_editor/supereditor_components_test.dart b/super_editor/test/super_editor/supereditor_components_test.dart index 4643215e19..62c0331190 100644 --- a/super_editor/test/super_editor/supereditor_components_test.dart +++ b/super_editor/test/super_editor/supereditor_components_test.dart @@ -100,7 +100,11 @@ class HintTextComponentBuilder implements ComponentBuilder { const HintTextComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This component builder can work with the standard paragraph view model. // We'll defer to the standard paragraph component builder to create it. return null; @@ -179,7 +183,11 @@ class _FakeImageComponentBuilder implements ComponentBuilder { const _FakeImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return null; } diff --git a/super_editor/test/super_editor/supereditor_input_ime_test.dart b/super_editor/test/super_editor/supereditor_input_ime_test.dart index 336a39b58c..f50ccf80e1 100644 --- a/super_editor/test/super_editor/supereditor_input_ime_test.dart +++ b/super_editor/test/super_editor/supereditor_input_ime_test.dart @@ -1107,7 +1107,7 @@ Paragraph two ); }); - group('text serialization and selected content', () { + group('text serialization and selected content >', () { test('within a single node is reported as a TextEditingValue', () { const text = "This is a paragraph of text."; @@ -1184,6 +1184,62 @@ Paragraph two ); }); + test('text within a composite node reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + CompositeDocumentNode("1", [ + ParagraphNode(id: "2", text: AttributedText(text)), + ]), + ]), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: CompositeNodePosition( + compositeNodeId: "1", + childNodeId: "2", + childNodePosition: TextNodePosition(offset: 10), + ), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph of text.", + ); + }); + + test('text within composite nodes and non-text in between reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + CompositeDocumentNode("1", [ + ParagraphNode(id: "2", text: AttributedText(text)), + ]), + HorizontalRuleNode(id: "3"), + CompositeDocumentNode("4", [ + ParagraphNode(id: "5", text: AttributedText(text)), + ]) + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + extent: DocumentPosition( + nodeId: "3", + nodePosition: TextNodePosition(offset: 19), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph of text.\n~\nThis is a paragraph| of text.", + ); + }); + test('text with non-text end-caps reported as a TextEditingValue', () { const text = "This is the first paragraph of text."; diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 8910d8b6a2..57042b32ed 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -1231,7 +1231,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 9399e5d83a..f8f7863fd0 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -1015,7 +1015,11 @@ class FakeImageComponentBuilder implements ComponentBuilder { final Color? fillColor; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return null; } @@ -1046,7 +1050,11 @@ class FakeImageComponentBuilder implements ComponentBuilder { /// [TaskNode] in a document. class ExpandingTaskComponentBuilder extends ComponentBuilder { @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } diff --git a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart index 122befc497..e5eddb17dc 100644 --- a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart +++ b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart @@ -2848,7 +2848,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; 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 d650b555ea..e4dbdad274 100644 --- a/super_editor/test/super_reader/super_reader_selection_test.dart +++ b/super_editor/test/super_reader/super_reader_selection_test.dart @@ -497,7 +497,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/test_goldens/editor/components/list_items_test.dart b/super_editor/test_goldens/editor/components/list_items_test.dart index 37915dd415..4cf7c89732 100644 --- a/super_editor/test_goldens/editor/components/list_items_test.dart +++ b/super_editor/test_goldens/editor/components/list_items_test.dart @@ -397,14 +397,18 @@ class _ListItemWithCustomStyleBuilder implements ComponentBuilder { final OrderedListNumeralStyle? numeralStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ListItemNode) { return null; } // Use the default component builder to create the view model, because we only want // to customize the style. - final viewModel = const ListItemComponentBuilder().createViewModel(document, node); + final viewModel = const ListItemComponentBuilder().createViewModel(document, node, componentBuilders); if (viewModel is UnorderedListItemComponentViewModel && dotStyle != null) { viewModel.dotStyle = dotStyle!; @@ -417,7 +421,9 @@ class _ListItemWithCustomStyleBuilder implements ComponentBuilder { @override Widget? createComponent( - SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { // We can use the default component for list items. return null; }