From b710beb2e22876a08351953772276f48671b0210 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 27 Aug 2024 16:07:00 -0700 Subject: [PATCH 1/5] WIP: Documents within documents --- .../quill/lib/editor/code_component.dart | 6 +- .../demos/components/demo_text_with_hint.dart | 6 +- .../components/demo_unselectable_hr.dart | 6 +- .../lib/demos/demo_animated_task_height.dart | 6 +- .../spelling_error_decorations.dart | 9 +- .../example_perf/lib/demos/rebuild_demo.dart | 6 +- super_editor/lib/src/core/document.dart | 265 ++++++++++++++++++ .../lib/src/default_editor/blockquote.dart | 6 +- .../default_editor/composite_component.dart | 253 +++++++++++++++++ .../src/default_editor/horizontal_rule.dart | 6 +- .../lib/src/default_editor/image.dart | 6 +- .../layout_single_column/_layout.dart | 5 + .../layout_single_column/_presenter.dart | 13 +- .../lib/src/default_editor/list_items.dart | 6 +- .../lib/src/default_editor/paragraph.dart | 6 +- .../lib/src/default_editor/super_editor.dart | 2 + .../lib/src/default_editor/tasks.dart | 6 +- .../src/default_editor/unknown_component.dart | 6 +- super_editor/test/super_editor/deletme.png | Bin 0 -> 25331 bytes .../super_editor_embedded_documents_test.dart | 32 +++ .../supereditor_component_selection_test.dart | 12 +- .../supereditor_components_test.dart | 12 +- .../supereditor_selection_test.dart | 6 +- .../super_editor/supereditor_test_tools.dart | 12 +- .../super_reader_selection_test.dart | 6 +- .../editor/components/list_items_test.dart | 12 +- 26 files changed, 684 insertions(+), 27 deletions(-) create mode 100644 super_editor/lib/src/default_editor/composite_component.dart create mode 100644 super_editor/test/super_editor/deletme.png create mode 100644 super_editor/test/super_editor/super_editor_embedded_documents_test.dart diff --git a/super_editor/clones/quill/lib/editor/code_component.dart b/super_editor/clones/quill/lib/editor/code_component.dart index cf2bb2b3de..2834b6097a 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 a4e45bc785..abaf244935 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/spelling_error_decorations.dart b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart index ad37d55b65..a2cf71714d 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 @@ -182,8 +182,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_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart index 5a3b9dc0f8..35844c9264 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 d6c4b413c5..c57056b7e6 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -438,6 +438,271 @@ extension InspectNodeAffinity on DocumentNode { } } +/// 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 with ChangeNotifier, Iterable { + CompositeDocumentNode(this.id, this._nodes) + : assert(_nodes.isNotEmpty, "CompositeDocumentNode's must contain at least 1 inner node."); + + @override + final String id; + + final List _nodes; + + int get nodeCount => _nodes.length; + + @override + Iterator get iterator => _nodes.iterator; + + @override + NodePosition get beginningPosition => _nodes.first.beginningPosition; + + @override + NodePosition get endPosition => _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); + } + + 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 copy() { + return CompositeDocumentNode(id, List.from(_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 6abc0cb27c..e1f25b2832 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -24,7 +24,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/composite_component.dart b/super_editor/lib/src/default_editor/composite_component.dart new file mode 100644 index 0000000000..5b3404c868 --- /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) { + 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/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 36a034243a..fab357cc29 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -50,7 +50,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 fe6d3f28c1..7359de6283 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -110,7 +110,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 5201b677db..225e6ed0f4 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 (int i = 0; i < _document.nodeCount; i += 1) { SingleColumnLayoutComponentViewModel? viewModel; for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, _document.getNodeAt(i)!); + viewModel = builder.createViewModel(_document, _document.getNodeAt(i)!, _componentBuilders); if (viewModel != null) { break; } @@ -366,7 +371,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 21401360ad..9e76545a97 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -120,7 +120,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 a484ab098d..e7a0b845b8 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -70,7 +70,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 f252ced672..532250a787 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'; @@ -1206,6 +1207,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 d54c49a3f2..26673f5e61 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -127,7 +127,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 ede6e9a0a0..1ba84f7ca3 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/test/super_editor/deletme.png b/super_editor/test/super_editor/deletme.png new file mode 100644 index 0000000000000000000000000000000000000000..7c49a3ad20fa1bad8721c37e97e52504ff7d7024 GIT binary patch literal 25331 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 literal 0 HcmV?d00001 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..60b7557dab --- /dev/null +++ b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart @@ -0,0 +1,32 @@ +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 { + 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 2657522ade..820da8a962 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; @@ -745,7 +749,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 3cc21996b4..062cc830ff 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_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 9437ea9d41..f002d16ca8 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 2b1b108a13..58e0775cfe 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -930,7 +930,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; } @@ -961,7 +965,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_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 9c22c4df42..3990e49dbe 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 = 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; } From 3f25a01c1923b6d294e0b4b14e8a58dfbe82679d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 1 Nov 2024 18:41:50 -0700 Subject: [PATCH 2/5] WIP: Docs in docs --- .../in_the_lab/feature_composite_nodes.dart | 301 ++++++++++++++++++ super_editor/example/lib/main.dart | 8 + super_editor/lib/super_editor.dart | 1 + .../supereditor_undeletable_content_test.dart | 6 +- 4 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart 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..272b3b6769 --- /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) { + 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/main.dart b/super_editor/example/lib/main.dart index 0b1b12c3d8..977ca99db2 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'; @@ -332,6 +333,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/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 39059cec3c..2988f43a64 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/supereditor_undeletable_content_test.dart b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart index 85a204c9c0..3bdd3aace2 100644 --- a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart +++ b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart @@ -1738,7 +1738,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; From ea9e64adffd10e10decf1b125f916810cca386fd Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 8 Nov 2024 12:04:20 -0800 Subject: [PATCH 3/5] WIP (broken): reworking IME mapping with composite nodes --- super_editor/lib/src/core/document.dart | 21 ++- .../common_editor_operations.dart | 1 + .../document_ime/document_serialization.dart | 126 +++++++++++++++++- 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index ab6dd251e8..eda9abcb4c 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -450,25 +450,31 @@ extension InspectNodeAffinity on DocumentNode { /// 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 with ChangeNotifier, Iterable { +class CompositeDocumentNode extends DocumentNode with ChangeNotifier { 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 - Iterator get iterator => _nodes.iterator; - - @override - NodePosition get beginningPosition => _nodes.first.beginningPosition; + NodePosition get beginningPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.first.id, + childNodePosition: _nodes.first.beginningPosition, + ); @override - NodePosition get endPosition => _nodes.last.endPosition; + NodePosition get endPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.last.id, + childNodePosition: _nodes.last.endPosition, + ); @override NodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { @@ -646,6 +652,9 @@ class CompositeDocumentNode extends DocumentNode with ChangeNotifier, Iterable "[CompositeNode] - $_nodes"; } /// A selection within a single [CompositeDocumentNode]. 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..a0ff08fbd3 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -438,6 +438,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/document_ime/document_serialization.dart b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart index 0a500e9d30..c99d585df3 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,8 +38,8 @@ class DocumentImeSerializer { final Document _doc; DocumentSelection selection; DocumentRange? composingRegion; - final imeRangesToDocTextNodes = {}; - final docTextNodesToImeRanges = {}; + final imeRangesToDocTextNodes = {}; + final docTextNodesToImeRanges = <_NodePath, TextRange>{}; final selectedNodes = []; late String imeText; final PrependedCharacterPolicy _prependedCharacterPolicy; @@ -46,6 +47,7 @@ class DocumentImeSerializer { 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 +67,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 +82,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 +107,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.text}'"); - 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.text); @@ -106,6 +119,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 @@ -371,6 +427,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"); } @@ -395,3 +459,51 @@ enum PrependedCharacterPolicy { include, exclude, } + +/// 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 + bool operator ==(Object other) => + identical(this, other) || + other is _NodePath && + runtimeType == other.runtimeType && + const DeepCollectionEquality().equals(nodeIds, other.nodeIds); + + @override + int get hashCode => nodeIds.hashCode; +} From 8f53966d3003dff28b91a8b7c78fd86d965f7a5f Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sat, 9 Nov 2024 11:34:07 -0800 Subject: [PATCH 4/5] WIP: Massaging IME serialization behaviors for composite nodes --- super_editor/lib/src/core/document.dart | 48 ++++++ .../document_ime/document_serialization.dart | 139 +++++++++--------- .../supereditor_input_ime_test.dart | 58 +++++++- 3 files changed, 176 insertions(+), 69 deletions(-) diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index eda9abcb4c..daffb06c2c 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -444,6 +444,54 @@ 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 + bool operator ==(Object other) => + identical(this, other) || + other is NodePath && + runtimeType == other.runtimeType && + const DeepCollectionEquality().equals(nodeIds, other.nodeIds); + + @override + int get hashCode => nodeIds.hashCode; +} + /// A [DocumentNode] that contains other [DocumentNode]s in a hierarchy. /// /// [CompositeDocumentNode]s can contain more [CompositeDocumentNode]s. There's no 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 c99d585df3..3b7b09776a 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 @@ -38,13 +38,34 @@ class DocumentImeSerializer { final Document _doc; DocumentSelection selection; DocumentRange? composingRegion; - final imeRangesToDocTextNodes = {}; - final docTextNodesToImeRanges = <_NodePath, TextRange>{}; + 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"); @@ -83,10 +104,10 @@ class DocumentImeSerializer { } var node = selectedNodes[i]; - final nodePath = _NodePath.forNode(node.id); + 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); + final serializedCharacterCount = _serializeCompositeNode(NodePath.forNode(node.id), node, buffer); characterCount += serializedCharacterCount; continue; @@ -119,7 +140,7 @@ class DocumentImeSerializer { editorImeLog.fine("IME serialization:\n'$imeText'"); } - int _serializeCompositeNode(_NodePath nodePath, CompositeDocumentNode node, StringBuffer buffer) { + int _serializeCompositeNode(NodePath nodePath, CompositeDocumentNode node, StringBuffer buffer) { int characterCount = 0; for (final innerNode in node.nodes) { final innerNodePath = nodePath.addSubPath(innerNode.id); @@ -139,7 +160,7 @@ class DocumentImeSerializer { return characterCount; } - int _serializeNonCompositeNode(_NodePath nodePath, DocumentNode node, StringBuffer buffer, int characterCount) { + int _serializeNonCompositeNode(NodePath nodePath, DocumentNode node, StringBuffer buffer, int characterCount) { if (node is! TextNode) { buffer.write('~'); @@ -321,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, + ); } } @@ -359,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.text}'"); + editorImeLog.shout(" ^ node content: '${_getTextNodeAtNodePath(entry.value).text.text}'"); } 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); @@ -459,51 +510,3 @@ enum PrependedCharacterPolicy { include, exclude, } - -/// 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 - bool operator ==(Object other) => - identical(this, other) || - other is _NodePath && - runtimeType == other.runtimeType && - const DeepCollectionEquality().equals(nodeIds, other.nodeIds); - - @override - int get hashCode => nodeIds.hashCode; -} 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 a6228d2ad4..817bd793aa 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."; From e65a5489649b305590d9726ada32bff090461b06 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 16 Jan 2025 14:03:39 -0800 Subject: [PATCH 5/5] Got almost all tests passing, except a couple sending a tree document to the IME. Next: Change DocumentPosition to include a NodePath instead of a nodeId. --- super_editor/lib/src/core/document.dart | 5 ++++- .../document_ime/document_serialization.dart | 10 ++++++++-- super_editor/test/super_editor/deletme.png | Bin 25331 -> 7089 bytes .../infrastructure/document_test.dart | 12 ++++++++++++ .../super_editor_embedded_documents_test.dart | 5 +++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index afe0d0812a..bc5d47cfff 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -518,6 +518,9 @@ class NodePath { NodePath addSubPath(String nodeId) => NodePath([...nodeIds, nodeId]); + @override + String toString() => "[NodePath] - ${nodeIds.join(" > ")}"; + @override bool operator ==(Object other) => identical(this, other) || @@ -526,7 +529,7 @@ class NodePath { const DeepCollectionEquality().equals(nodeIds, other.nodeIds); @override - int get hashCode => nodeIds.hashCode; + int get hashCode => const ListEquality().hash(nodeIds); } /// A [DocumentNode] that contains other [DocumentNode]s in a hierarchy. 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 f6dd5110bd..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 @@ -453,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; diff --git a/super_editor/test/super_editor/deletme.png b/super_editor/test/super_editor/deletme.png index 7c49a3ad20fa1bad8721c37e97e52504ff7d7024..6c50ffce875a7628831be9e91d2550e7dc0bb720 100644 GIT binary patch literal 7089 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 C{knGm literal 25331 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 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 index 60b7557dab..10a9fd191b 100644 --- a/super_editor/test/super_editor/super_editor_embedded_documents_test.dart +++ b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart @@ -11,6 +11,11 @@ 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: [