From b710beb2e22876a08351953772276f48671b0210 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 27 Aug 2024 16:07:00 -0700 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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: [ From 71be22aa83eb2bdbd19a37155e20514d4e88ae6d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 27 Jan 2025 23:18:50 -0800 Subject: [PATCH 6/6] WIP: Updating all call sits to update DocumentPosition to use a node path --- .../clones/quill/lib/editor/editor.dart | 8 +- .../clones/quill/lib/editor/toolbar.dart | 23 +-- .../demo_mobile_editing_android.dart | 2 +- .../lib/demos/example_editor/_toolbar.dart | 33 ++-- .../demos/example_editor/example_editor.dart | 2 +- .../demos/in_the_lab/feature_action_tags.dart | 2 +- .../url_launching_spot_checks.dart | 2 +- .../demos/mobile_chat/demo_mobile_chat.dart | 2 +- .../demos/super_reader/demo_super_reader.dart | 11 +- .../marketing_video/main_marketing_video.dart | 13 +- super_editor/example_docs/lib/toolbar.dart | 29 +-- super_editor/lib/src/core/document.dart | 91 ++++++--- .../lib/src/core/document_selection.dart | 100 ++++++++-- super_editor/lib/src/core/editor.dart | 50 ++++- .../lib/src/default_editor/box_component.dart | 69 ++++++- .../common_editor_operations.dart | 186 +++++++++--------- .../composer/composer_reactions.dart | 2 +- .../default_document_editor_reactions.dart | 38 ++-- .../document_gestures_mouse.dart | 4 +- .../document_gestures_touch_android.dart | 4 +- .../document_gestures_touch_ios.dart | 6 +- .../document_keyboard_actions.dart | 4 +- .../document_ime/document_delta_editing.dart | 5 +- .../document_ime/document_serialization.dart | 10 +- .../document_ime/mobile_toolbar.dart | 2 +- .../layout_single_column/_layout.dart | 16 +- .../layout_single_column/_presenter.dart | 5 + .../lib/src/default_editor/list_items.dart | 2 +- .../default_editor/multi_node_editing.dart | 24 +-- .../lib/src/default_editor/paragraph.dart | 15 +- .../tap_handlers/tap_handlers.dart | 2 +- .../lib/src/default_editor/tasks.dart | 4 +- super_editor/lib/src/default_editor/text.dart | 69 +++++-- .../text_tokenizing/action_tags.dart | 12 +- .../text_tokenizing/pattern_tags.dart | 12 +- .../text_tokenizing/stable_tags.dart | 74 ++++--- .../default_editor/text_tokenizing/tags.dart | 19 +- .../lib/src/default_editor/text_tools.dart | 8 +- .../selection_operations.dart | 51 +++-- .../attribution_layout_bounds.dart | 5 +- .../android/long_press_selection.dart | 12 +- .../platforms/ios/ios_document_controls.dart | 2 +- .../platforms/ios/long_press_selection.dart | 4 +- .../supereditor_inspector.dart | 12 +- .../test/super_editor/bug_fix_test.dart | 34 +--- .../components/block_node_test.dart | 151 ++++---------- .../components/horizontal_rule_test.dart | 14 +- .../components/list_items_test.dart | 110 +++-------- .../components/paragraph_test.dart | 10 +- .../super_editor/components/task_test.dart | 49 +---- ...add_paragraph_at_end_tap_handler_test.dart | 14 +- .../super_editor_desktop_selection_test.dart | 20 +- .../common_editor_operations_test.dart | 47 ++--- .../document_attributions_test.dart | 68 +------ .../document_selection_test.dart | 173 ++++++++++------ .../infrastructure/editor_test.dart | 153 +++++--------- .../infrastructure/mutable_document_test.dart | 32 +-- ..._editor_android_overlay_controls_test.dart | 9 +- .../super_editor_android_selection_test.dart | 117 +++++------ 59 files changed, 1029 insertions(+), 1018 deletions(-) diff --git a/super_editor/clones/quill/lib/editor/editor.dart b/super_editor/clones/quill/lib/editor/editor.dart index 94e43324bb..6cb63a99f4 100644 --- a/super_editor/clones/quill/lib/editor/editor.dart +++ b/super_editor/clones/quill/lib/editor/editor.dart @@ -117,7 +117,7 @@ class ClearSelectedStylesCommand extends EditCommand { final document = context.find(Editor.documentKey); if (selection.isCollapsed) { // Remove block style. - final selectedNode = document.getNodeById(selection.extent.nodeId); + final selectedNode = document.getNodeById(selection.extent.targetNodeId); if (selectedNode is! TextNode) { // Can't remove text block styles from a non-text node. return; @@ -331,7 +331,7 @@ class ToggleTextBlockFormatCommand extends EditCommand { } final document = context.find(Editor.documentKey); - final selectedNode = document.getNodeById(selection.extent.nodeId); + final selectedNode = document.getNodeById(selection.extent.targetNodeId); if (selectedNode is! TextNode) { // Can't apply a block level text format to a non-text node. return; @@ -492,7 +492,7 @@ class ConvertTextBlockToFormatCommand extends EditCommand { } final document = context.find(Editor.documentKey); - final selectedNode = document.getNodeById(selection.extent.nodeId); + final selectedNode = document.getNodeById(selection.extent.targetNodeId); if (selectedNode is! TextNode) { // Can't apply a block level text format to a non-text node. return; @@ -609,7 +609,7 @@ ExecutionInstruction enterToInsertNewlineInCodeBlock({ if (selection == null || (selection.base.nodeId != selection.extent.nodeId)) { return ExecutionInstruction.continueExecution; } - final selectedNode = editContext.document.getNodeById(selection.extent.nodeId)!; + final selectedNode = editContext.document.getNodeById(selection.extent.targetNodeId)!; if (selectedNode is! ParagraphNode || selectedNode.metadata["blockType"] != codeAttribution) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/clones/quill/lib/editor/toolbar.dart b/super_editor/clones/quill/lib/editor/toolbar.dart index 0a187a9ebb..0d53e4f8f0 100644 --- a/super_editor/clones/quill/lib/editor/toolbar.dart +++ b/super_editor/clones/quill/lib/editor/toolbar.dart @@ -160,7 +160,8 @@ class _FormattingToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); - final textNode = widget.editor.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNodePath = selection.extent.documentPath; + final textNode = widget.editor.document.getNodeById(textNodePath.targetNodeId) as TextNode; final text = textNode.text; final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); @@ -171,11 +172,11 @@ class _FormattingToolbarState extends State { AddTextAttributionsRequest( documentRange: DocumentRange( start: DocumentPosition( - nodeId: textNode.id, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: trimmedRange.start), ), end: DocumentPosition( - nodeId: textNode.id, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: trimmedRange.end), ), ), @@ -249,7 +250,7 @@ class _FormattingToolbarState extends State { return; } - final extentNode = _document.getNodeById(selection.extent.nodeId); + final extentNode = _document.getNodeById(selection.extent.targetNodeId); if (extentNode is! TextNode) { return; } @@ -275,7 +276,7 @@ class _FormattingToolbarState extends State { return; } - final extentNode = _document.getNodeById(selection.extent.nodeId); + final extentNode = _document.getNodeById(selection.extent.targetNodeId); if (extentNode is! TextNode) { return; } @@ -301,7 +302,7 @@ class _FormattingToolbarState extends State { DocumentNode? extentNode; FeatherTextBlock? selectedBlockFormat; if (selection != null) { - extentNode = _document.getNodeById(selection.extent.nodeId); + extentNode = _document.getNodeById(selection.extent.targetNodeId); if (extentNode is TextNode) { selectedBlockFormat = selection.base.nodeId == selection.extent.nodeId ? FeatherTextBlock.fromNode(extentNode) : null; @@ -702,7 +703,7 @@ class _NamedTextSizeSelectorState extends State<_NamedTextSizeSelector> { return; } - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.editor.document.getNodeById(selection.extent.targetNodeId); if (selectedNode is! TextNode) { return; } @@ -834,7 +835,7 @@ class _HeaderSelectorState extends State<_HeaderSelector> { return; } - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.editor.document.getNodeById(selection.extent.targetNodeId); if (selectedNode is! TextNode) { return; } @@ -853,7 +854,7 @@ class _HeaderSelectorState extends State<_HeaderSelector> { final selection = composer.selection; var selectedHeaderLevel = "Normal"; if (selection != null && selection.base.nodeId == selection.extent.nodeId) { - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.editor.document.getNodeById(selection.extent.targetNodeId); if (selectedNode is ParagraphNode) { selectedHeaderLevel = _headerLevelNames[selectedNode.getMetadataValue("blockType")] ?? "Normal"; } @@ -1185,7 +1186,7 @@ class _AlignmentButtonState extends State<_AlignmentButton> { widget.editor.execute([ ChangeParagraphAlignmentRequest( - nodeId: selection.extent.nodeId, + nodeId: selection.extent.targetNodeId, alignment: newAlignment, ), ]); @@ -1219,7 +1220,7 @@ class _AlignmentButtonState extends State<_AlignmentButton> { } final document = widget.editor.document; - final selectedNode = document.getNodeById(selection.extent.nodeId); + final selectedNode = document.getNodeById(selection.extent.targetNodeId); if (selectedNode == null) { // Default to "left" when there's no selection. This only effects the // icon that's displayed on the toolbar. diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart index 263ad87902..ceed5ad750 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart @@ -63,7 +63,7 @@ class _MobileEditingAndroidDemoState extends State { return; } - final selectedNode = _doc.getNodeById(_composer.selection!.extent.nodeId); + final selectedNode = _doc.getNodeById(_composer.selection!.extent.targetNodeId); if (selectedNode is ListItemNode) { setState(() { _imeConfiguration = _imeConfiguration.copyWith( diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 78499bad08..8c80d2990c 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -115,7 +115,7 @@ class _EditorToolbarState extends State { return false; } - final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.targetNodeId); return selectedNode is ParagraphNode || selectedNode is ListItemNode; } @@ -123,7 +123,7 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. _TextType _getCurrentTextType() { - final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.targetNodeId); if (selectedNode is ParagraphNode) { final type = selectedNode.getMetadataValue('blockType'); @@ -149,7 +149,7 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. TextAlign _getCurrentTextAlignment() { - final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.targetNodeId); if (selectedNode is ParagraphNode) { final align = selectedNode.getMetadataValue('textAlign'); switch (align) { @@ -177,7 +177,7 @@ class _EditorToolbarState extends State { return false; } - final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.targetNodeId); return selectedNode is ParagraphNode; } @@ -197,14 +197,14 @@ class _EditorToolbarState extends State { if (_isListItem(existingTextType) && _isListItem(newType)) { widget.editor!.execute([ ChangeListItemTypeRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, newType: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), ]); } else if (_isListItem(existingTextType) && !_isListItem(newType)) { widget.editor!.execute([ ConvertListItemToParagraphRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, paragraphMetadata: { 'blockType': _getBlockTypeAttribution(newType), }, @@ -213,7 +213,7 @@ class _EditorToolbarState extends State { } else if (!_isListItem(existingTextType) && _isListItem(newType)) { widget.editor!.execute([ ConvertParagraphToListItemRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, type: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), ]); @@ -221,7 +221,7 @@ class _EditorToolbarState extends State { // Apply a new block type to an existing paragraph node. widget.editor!.execute([ ChangeParagraphBlockTypeRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, blockType: _getBlockTypeAttribution(newType), ), ]); @@ -325,7 +325,7 @@ class _EditorToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.document.getNodeById(selection.extent.targetNodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -346,7 +346,7 @@ class _EditorToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.document.getNodeById(selection.extent.targetNodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -399,7 +399,8 @@ class _EditorToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); - final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; + final selectedNodePath = selection.extent.documentPath; + final textNode = widget.document.getNodeById(selectedNodePath.targetNodeId) as TextNode; final text = textNode.text; final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); @@ -410,11 +411,11 @@ class _EditorToolbarState extends State { AddTextAttributionsRequest( documentRange: DocumentRange( start: DocumentPosition( - nodeId: textNode.id, + documentPath: selectedNodePath, nodePosition: TextNodePosition(offset: trimmedRange.start), ), end: DocumentPosition( - nodeId: textNode.id, + documentPath: selectedNodePath, nodePosition: TextNodePosition(offset: trimmedRange.end), ), ), @@ -459,7 +460,7 @@ class _EditorToolbarState extends State { widget.editor!.execute([ ChangeParagraphAlignmentRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, alignment: newAlignment, ), ]); @@ -819,11 +820,11 @@ class ImageFormatToolbar extends StatefulWidget { class _ImageFormatToolbarState extends State { void _makeImageConfined() { - widget.setWidth(widget.composer.selection!.extent.nodeId, null); + widget.setWidth(widget.composer.selection!.extent.targetNodeId, null); } void _makeImageFullBleed() { - widget.setWidth(widget.composer.selection!.extent.nodeId, double.infinity); + widget.setWidth(widget.composer.selection!.extent.targetNodeId, double.infinity); } @override diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 54445d17c1..a19059aaa5 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -115,7 +115,7 @@ class _ExampleEditorState extends State { return; } - final selectedNode = _doc.getNodeById(selection.extent.nodeId); + final selectedNode = _doc.getNodeById(selection.extent.targetNodeId); if (selectedNode is ImageNode) { appLog.fine("Showing image toolbar"); diff --git a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart index eaef52cacd..5dff87795d 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart @@ -316,7 +316,7 @@ class ConvertSelectedTextNodeCommand extends EditCommand { return; } - final oldNode = document.getNodeById(composer.selection!.extent.nodeId) as TextNode; + final oldNode = document.getNodeById(composer.selection!.extent.targetNodeId) as TextNode; late final TextNode newNode; switch (newType) { diff --git a/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart b/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart index 264fd3f540..d787a20c90 100644 --- a/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart +++ b/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart @@ -63,7 +63,7 @@ obsidian://open?vault=my-vault ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: _editor.document.last.id, + documentPath: NodePath.forNode(_editor.document.last.id), nodePosition: (_editor.document.last as TextNode).endPosition, ), ), diff --git a/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart index bc32a6b19e..8cecb1ceb0 100644 --- a/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart +++ b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart @@ -78,7 +78,7 @@ class _MobileChatDemoState extends State { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: document.last.id, + documentPath: NodePath.forNode(document.last.id), nodePosition: document.last.endPosition, ), ), diff --git a/super_editor/example/lib/demos/super_reader/demo_super_reader.dart b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart index a92554bc02..95112954e5 100644 --- a/super_editor/example/lib/demos/super_reader/demo_super_reader.dart +++ b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart @@ -113,16 +113,7 @@ class _SuperReaderDemoState extends State { return; } - _selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: _document.first.id, - nodePosition: _document.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: _document.last.id, - nodePosition: _document.last.endPosition, - ), - ); + _selection.value = _document.selectAll(); } @override diff --git a/super_editor/example/lib/marketing_video/main_marketing_video.dart b/super_editor/example/lib/marketing_video/main_marketing_video.dart index 991462f8ec..c955ae7a73 100644 --- a/super_editor/example/lib/marketing_video/main_marketing_video.dart +++ b/super_editor/example/lib/marketing_video/main_marketing_video.dart @@ -31,7 +31,7 @@ class _MarketingVideoState extends State { _composer = MutableDocumentComposer( initialSelection: DocumentSelection.collapsed( position: DocumentPosition( - nodeId: _document.first.id, + documentPath: _document.getPathByNodeId(_document.first.id)!, nodePosition: _document.first.endPosition, ), ), @@ -305,16 +305,7 @@ class DocumentEditingRobot { () { _editor.execute([ ChangeSelectionRequest( - DocumentSelection( - base: DocumentPosition( - nodeId: _document.first.id, - nodePosition: _document.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: _document.last.id, - nodePosition: _document.last.endPosition, - ), - ), + _document.selectAll(), SelectionChangeType.expandSelection, SelectionReason.userInteraction, ), diff --git a/super_editor/example_docs/lib/toolbar.dart b/super_editor/example_docs/lib/toolbar.dart index dacf6f7b27..1c583aeadf 100644 --- a/super_editor/example_docs/lib/toolbar.dart +++ b/super_editor/example_docs/lib/toolbar.dart @@ -124,7 +124,7 @@ class _DocsEditorToolbarState extends State { // Apply a new block type to an existing paragraph node. widget.editor.execute([ ChangeParagraphBlockTypeRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.documentPath.targetNodeId, blockType: _getBlockTypeAttribution(newType), ), ]); @@ -257,7 +257,8 @@ class _DocsEditorToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); - final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNodePath = selection.extent.documentPath; + final textNode = widget.document.getNodeById(textNodePath.targetNodeId) as TextNode; final text = textNode.text; final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); @@ -268,11 +269,11 @@ class _DocsEditorToolbarState extends State { AddTextAttributionsRequest( documentRange: DocumentRange( start: DocumentPosition( - nodeId: textNode.id, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: trimmedRange.start), ), end: DocumentPosition( - nodeId: textNode.id, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: trimmedRange.end), ), ), @@ -296,7 +297,7 @@ class _DocsEditorToolbarState extends State { widget.editor.execute([ ChangeParagraphAlignmentRequest( - nodeId: widget.composer.selection!.extent.nodeId, + nodeId: widget.composer.selection!.extent.targetNodeId, alignment: newAlignment, ), ]); @@ -310,14 +311,14 @@ class _DocsEditorToolbarState extends State { return; } - final node = widget.document.getNodeById(selection.extent.nodeId); + final node = widget.document.getNodeById(selection.extent.targetNodeId); if (node is TaskNode) { widget.editor.execute([ DeleteUpstreamAtBeginningOfNodeRequest(node), ]); } else { widget.editor.execute([ - ConvertParagraphToTaskRequest(nodeId: selection.extent.nodeId), + ConvertParagraphToTaskRequest(nodeId: selection.extent.targetNodeId), ]); } } @@ -330,7 +331,7 @@ class _DocsEditorToolbarState extends State { return; } - final node = widget.document.getNodeById(selection.extent.nodeId); + final node = widget.document.getNodeById(selection.extent.targetNodeId); if (node is ListItemNode) { widget.editor.execute([ ConvertListItemToParagraphRequest(nodeId: node.id, paragraphMetadata: node.metadata), @@ -338,7 +339,7 @@ class _DocsEditorToolbarState extends State { } else { widget.editor.execute([ ConvertParagraphToListItemRequest( - nodeId: selection.extent.nodeId, + nodeId: selection.extent.targetNodeId, type: ListItemType.unordered, ), ]); @@ -353,7 +354,7 @@ class _DocsEditorToolbarState extends State { return; } - final node = widget.document.getNodeById(selection.extent.nodeId); + final node = widget.document.getNodeById(selection.extent.targetNodeId); if (node is ListItemNode) { widget.editor.execute([ ConvertListItemToParagraphRequest(nodeId: node.id, paragraphMetadata: node.metadata), @@ -361,7 +362,7 @@ class _DocsEditorToolbarState extends State { } else { widget.editor.execute([ ConvertParagraphToListItemRequest( - nodeId: selection.extent.nodeId, + nodeId: selection.extent.targetNodeId, type: ListItemType.ordered, ), ]); @@ -472,7 +473,7 @@ class _DocsEditorToolbarState extends State { if (widget.composer.selection == null) { return TextAlign.left; } - final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.targetNodeId); if (selectedNode is ParagraphNode) { final align = selectedNode.getMetadataValue('textAlign'); switch (align) { @@ -517,7 +518,7 @@ class _DocsEditorToolbarState extends State { return false; } - final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.targetNodeId); return selectedNode is ParagraphNode; } @@ -529,7 +530,7 @@ class _DocsEditorToolbarState extends State { return null; } - final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.targetNodeId); if (selectedNode is ParagraphNode) { return (selectedNode.getMetadataValue('blockType') as NamedAttribution).id; } diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index bc5d47cfff..94b9e235cc 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -26,6 +26,19 @@ abstract class Document implements Iterable { @override bool get isEmpty; + // FIXME: Started defining these, but not sure if there's an unambiguous definition or not. + // /// Returns the first [DocumentPosition] within the document. + // /// + // /// This is the position for which attempting to move backward in content + // /// order would fail to move the caret. + // DocumentPosition get beginning; + // + // /// Returns the last [DocumentPosition] within the document. + // /// + // /// This is the position for which attempting to move forward in content + // /// order would fail to move the caret. + // DocumentPosition get end; + /// Returns the first [DocumentNode] in this [Document], or `null` if this /// [Document] is empty. DocumentNode? get firstOrNull; @@ -38,6 +51,19 @@ abstract class Document implements Iterable { /// if no such node exists. DocumentNode? getNodeById(String nodeId); + /// Returns the [DocumentNode] at the given [path] within this [Document], + /// or `null` if no such node exists. + DocumentNode? getNodeAtPath(NodePath path); + + /// Returns the [NodePath] for the node with the given [nodeId]. + NodePath? getPathByNodeId(String nodeId); + + /// Returns the index of the given node, within the node's parent. + /// + /// Every parent node has a list of children. That list of children imposes + /// an order. + int getNodeIndexInParent(String nodeId); + /// Returns the [DocumentNode] at the given [index], or [null] /// if no such node exists. DocumentNode? getNodeAt(int index); @@ -271,14 +297,24 @@ class DocumentPosition { /// ); /// ``` const DocumentPosition({ - required this.nodeId, + required this.documentPath, required this.nodePosition, }); - /// ID of a [DocumentNode] within a [Document]. - final String nodeId; + /// The node path within the `Document` where this position sits. + /// + /// Nominally, this path simply refers to the ID of a node in the + /// `Document`. However, some nodes contain other nodes, in which + /// case this path includes each node along the way. + final NodePath documentPath; + + @Deprecated("Use targetNodeId instead") + String get nodeId => targetNodeId; + + /// Returns the ID of the node that this path points to. + String get targetNodeId => documentPath.targetNodeId; - /// Node-specific representation of a position. + /// The position within the node at the end of the [nodePath]. /// /// For example: a paragraph node might use a [TextNodePosition]. final NodePosition nodePosition; @@ -286,9 +322,9 @@ class DocumentPosition { /// Whether this position within the document is equivalent to the given /// [other] [DocumentPosition]. /// - /// Equivalency is determined by the [NodePosition]. For example, given two - /// [TextNodePosition]s, if both of them point to the same character, but one - /// has an upstream affinity and the other a downstream affinity, the two + /// The difference between equality and equivalency is determined by the [NodePosition]. + /// For example, given two [TextNodePosition]s, if both of them point to the same character, + /// but one has an upstream affinity and the other a downstream affinity, the two /// [TextNodePosition]s are considered "non-equal", but they're considered /// "equivalent" because both [TextNodePosition]s point to the same location /// within the document. @@ -306,18 +342,18 @@ class DocumentPosition { /// Creates a new [DocumentPosition] based on the current position, with the /// provided parameters overridden. DocumentPosition copyWith({ - String? nodeId, + NodePath? documentPath, NodePosition? nodePosition, }) { return DocumentPosition( - nodeId: nodeId ?? this.nodeId, + documentPath: documentPath ?? this.documentPath, nodePosition: nodePosition ?? this.nodePosition, ); } @override String toString() { - return '[DocumentPosition] - node: "$nodeId", position: ($nodePosition)'; + return '[DocumentPosition] - path: "$nodeId", position: ($nodePosition)'; } } @@ -488,34 +524,29 @@ extension InspectNodeAffinity on DocumentNode { /// 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); + /// All node IDs along this path, ordered from the root node within the + /// `Document`, to the [targetNodeId]. final List nodeIds; + /// The depth of this node in the document tree, with root nodes having + /// a depth of zero. + int get depth => nodeIds.length - 1; + + /// Returns `true` if this path is at least [depth] deep. + bool hasDepth(int depth) => depth < nodeIds.length; + + /// Returns the node ID within this path at the given [depth]. + String atDepth(int depth) => nodeIds[depth]; + + /// The [DocumentNode] to which this path points. + String get targetNodeId => nodeIds.last; + NodePath addSubPath(String nodeId) => NodePath([...nodeIds, nodeId]); @override diff --git a/super_editor/lib/src/core/document_selection.dart b/super_editor/lib/src/core/document_selection.dart index c669d7b7b4..9afa1bc335 100644 --- a/super_editor/lib/src/core/document_selection.dart +++ b/super_editor/lib/src/core/document_selection.dart @@ -308,11 +308,11 @@ extension InspectDocumentAffinity on Document { return getAffinityForSelection( DocumentSelection( base: DocumentPosition( - nodeId: base.id, + documentPath: getPathByNodeId(base.id)!, nodePosition: base.beginningPosition, ), extent: DocumentPosition( - nodeId: extent.id, + documentPath: getPathByNodeId(extent.id)!, nodePosition: extent.beginningPosition, ), ), @@ -335,18 +335,83 @@ extension InspectDocumentAffinity on Document { throw Exception('No such position in document: $extent'); } - late TextAffinity affinity; - if (base.nodeId != extent.nodeId) { - affinity = getNodeIndexById(base.nodeId) < getNodeIndexById(extent.nodeId) + // A document is a tree, but it's a tree where every position in that tree has + // a conceptual downstream and upstream direction. + // + // In the nominal case, we're dealing with a couple of top-level nodes. In that + // case, whichever node comes first in the root node list is the upstream node. + // + // The more complicated case is when one or both of the nodes are sub-nodes of + // other nodes. In that scenario, the nodes might have an ancestor/descendant + // relationship, sibling relationship, or cousin relationship. + // + // The following examples demonstrate how we define affinity. + // + // Root siblings: + // + // Document + // > 1: Upstream + // > 2: ... + // > 3: ... + // > 4: Downstream + // + // Descendant siblings: + // + // Document + // > 1: + // > 1.1: Upstream + // > 1.2: ... + // > 1.3: Downstream + // > 2: + // + // Cousins: + // + // Document + // > 1: + // > 1.1: Upstream + // > 2: + // > 2.1: + // > 2.1.1: Downstream + // + // Ancestor/Descendant: + // + // Document + // > 1: Upstream + // > 1.1: Downstream + // + // To determine affinity, we do a level-by-level position comparison + // between the two node paths. I.e., we compare their top-level node + // positions. If those are equal, we compare their 2nd level node positions. + // Etc. If at any point the node positions aren't equal, the path with + // the upstream node position is marked upstream, and the other downstream. + // + // If, during the level-by-level path comparison, one path runs out of nodes + // before the other, then we have an ancestor/descendant relationship, in which + // case the ancestor is marked as upstream, and the descendant is marked as downstream. + + int depth = 0; + do { + final baseIndex = getNodeIndexInParent(base.documentPath.atDepth(depth)); + final extentIndex = getNodeIndexInParent(extent.documentPath.atDepth(depth)); + if (baseIndex < extentIndex) { + return TextAffinity.downstream; + } + if (extentIndex < baseIndex) { + return TextAffinity.upstream; + } + + depth += 1; + } while (depth < base.documentPath.depth && depth < extent.documentPath.depth); + + if (base.documentPath.depth != extent.documentPath.depth) { + // One of these nodes is a descendant of the other. + return base.documentPath.depth < extent.documentPath.depth // ? TextAffinity.downstream : TextAffinity.upstream; - } else { - // The selection is within the same node. Ask the node which position - // comes first. - affinity = extentNode.getAffinityBetween(base: base.nodePosition, extent: extent.nodePosition); } - return affinity; + // These paths point to the same node. Defer to node affinity. + return extentNode.getAffinityBetween(base: base.nodePosition, extent: extent.nodePosition); } } @@ -363,6 +428,19 @@ extension InspectDocumentRange on Document { } extension InspectDocumentSelection on Document { + DocumentSelection selectAll() { + return DocumentSelection( + base: DocumentPosition( + documentPath: NodePath.forNode(first.id), + nodePosition: first.beginningPosition, + ), + extent: DocumentPosition( + documentPath: NodePath.forNode(last.id), + nodePosition: first.endPosition, + ), + ); + } + /// Returns a list of all the `DocumentNodes` within the given [selection], ordered /// from upstream to downstream. List getNodesInContentOrder(DocumentSelection selection) { @@ -384,7 +462,7 @@ extension InspectDocumentSelection on Document { // Both document positions are in the same node. Figure out which // node position comes first. - final theNode = getNodeById(docPosition1.nodeId)!; + final theNode = getNodeById(docPosition1.targetNodeId)!; return theNode.selectUpstreamPosition(docPosition1.nodePosition, docPosition2.nodePosition) == docPosition1.nodePosition ? docPosition1 diff --git a/super_editor/lib/src/core/editor.dart b/super_editor/lib/src/core/editor.dart index 2a873f5f2f..c2abb4d9d7 100644 --- a/super_editor/lib/src/core/editor.dart +++ b/super_editor/lib/src/core/editor.dart @@ -1121,6 +1121,54 @@ class MutableDocument with Iterable implements Document, Editable return _nodesById[nodeId]; } + @override + DocumentNode? getNodeAtPath(NodePath path) { + return _nodesById[path.targetNodeId]; + } + + @override + int getNodeIndexInParent(String nodeId) { + final nodePath = getPathByNodeId(nodeId)!; + if (nodePath.depth == 0) { + // This is a root node. Return its index in the root list. + return _nodes.indexWhere((node) => node.id == nodeId); + } + + // This node has a parent. Find the parent and then find the child index. + final parentNodeId = nodePath.atDepth(nodePath.depth - 1); + final parentNode = getNodeById(parentNodeId)!; + return (parentNode as CompositeDocumentNode).nodes.toList().indexWhere((node) => node.id == nodeId); + } + + @override + NodePath? getPathByNodeId(String nodeId) { + // FIXME: Instead of crawling the tree every call, create a cache that takes + // a node ID as the key, and holds each node's path as the value. + + final queue = <(NodePath, List)>[ + (const NodePath([]), [..._nodes]) + ]; + while (queue.isNotEmpty) { + final (parentNodePath, children) = queue.removeAt(0); + + for (final child in children) { + if (child.id == nodeId) { + // This `child` is the node we're searching for. It's path is its + // parent path + itself. + return parentNodePath.addSubPath(nodeId); + } + + if (child is CompositeDocumentNode) { + // This child might also have children. Add them to the visit queue. + queue.add((parentNodePath.addSubPath(nodeId), [...child.nodes])); + } + } + } + + // We never found the node. + return null; + } + @override DocumentNode? getNodeAt(int index) { if (index < 0 || index >= _nodes.length) { @@ -1174,7 +1222,7 @@ class MutableDocument with Iterable implements Document, Editable } @override - DocumentNode? getNode(DocumentPosition position) => getNodeById(position.nodeId); + DocumentNode? getNode(DocumentPosition position) => getNodeById(position.documentPath.targetNodeId); @override List getNodesInside(DocumentPosition position1, DocumentPosition position2) { diff --git a/super_editor/lib/src/default_editor/box_component.dart b/super_editor/lib/src/default_editor/box_component.dart index 7c1d3ae39c..3b0f145161 100644 --- a/super_editor/lib/src/default_editor/box_component.dart +++ b/super_editor/lib/src/default_editor/box_component.dart @@ -17,6 +17,62 @@ final _log = Logger(scope: 'box_component.dart'); /// Base implementation for a [DocumentNode] that only supports [UpstreamDownstreamNodeSelection]s. @immutable abstract class BlockNode extends DocumentNode { + /// A factory method for a collapsed [DocumentSelection] within a [BlockNode] + /// at the given [nodePath], on the upstream edge. + /// + /// This factory is provided as a convenience for less verbose code. + static DocumentSelection caretAtUpstreamEdge(List nodePath) { + return DocumentSelection.collapsed( + position: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + ); + } + + /// A factory method for a collapsed [DocumentSelection] within a [BlockNode] + /// at the given [nodePath], on the downstream edge. + /// + /// This factory is provided as a convenience for less verbose code. + static DocumentSelection caretAtDownstreamEdge(List nodePath) { + return DocumentSelection.collapsed( + position: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ); + } + + /// A factory method for a collapsed [DocumentSelection] within a [BlockNode] + /// at the given [nodePath], on the edge with the given [affinity]. + /// + /// This factory is provided as a convenience for less verbose code. + static DocumentSelection caretAtEdge(List nodePath, TextAffinity affinity) { + return DocumentSelection.collapsed( + position: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: UpstreamDownstreamNodePosition(affinity), + ), + ); + } + + /// A factory method for a [DocumentSelection] that contains all of the + /// [BlockNode] at the given [nodePath]. + /// + /// This factory is provided as a convenience for less verbose code. + static DocumentSelection selectNodeInDoc(List nodePath) { + return DocumentSelection( + base: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ); + } + BlockNode({ Map? metadata, }) : super(metadata: metadata); @@ -332,14 +388,19 @@ class DeleteUpstreamAtBeginningOfBlockNodeCommand extends EditCommand { final composer = context.find(Editor.composerKey); final documentLayoutEditable = context.find(Editor.layoutKey); - final deletionPosition = DocumentPosition(nodeId: node.id, nodePosition: node.beginningPosition); + final deletionPosition = DocumentPosition( + documentPath: document.getPathByNodeId(node.id)!, + nodePosition: node.beginningPosition, + ); final nodePosition = deletionPosition.nodePosition as UpstreamDownstreamNodePosition; if (nodePosition.affinity == TextAffinity.downstream) { // The caret is sitting on the downstream edge of block-level content. Delete the // whole block by replacing it with an empty paragraph. executor.executeCommand( - ReplaceNodeWithEmptyParagraphWithCaretCommand(nodeId: deletionPosition.nodeId), + ReplaceNodeWithEmptyParagraphWithCaretCommand( + nodeId: deletionPosition.documentPath.targetNodeId, + ), ); return; } @@ -382,7 +443,7 @@ class DeleteUpstreamAtBeginningOfBlockNodeCommand extends EditCommand { return; } - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.documentPath.targetNodeId); if (node == null) { return; } @@ -396,7 +457,7 @@ class DeleteUpstreamAtBeginningOfBlockNodeCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), 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 c99970d2c6..4c38781bc8 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -69,7 +69,7 @@ class CommonEditorOperations { /// or [false] if the given [documentPosition] could not be /// resolved to a location within the [Document]. bool insertCaretAtPosition(DocumentPosition documentPosition) { - if (document.getNodeById(documentPosition.nodeId) == null) { + if (document.getNodeById(documentPosition.documentPath.targetNodeId) == null) { return false; } @@ -133,10 +133,10 @@ class CommonEditorOperations { required DocumentPosition baseDocumentPosition, required DocumentPosition extentDocumentPosition, }) { - if (document.getNodeById(baseDocumentPosition.nodeId) == null) { + if (document.getNodeById(baseDocumentPosition.documentPath.targetNodeId) == null) { return false; } - if (document.getNodeById(extentDocumentPosition.nodeId) == null) { + if (document.getNodeById(extentDocumentPosition.documentPath.targetNodeId) == null) { return false; } @@ -165,11 +165,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); + final selectedNode = document.getNodeById(composer.selection!.extent.documentPath.targetNodeId); if (selectedNode is! TextNode) { return false; } @@ -195,6 +195,7 @@ class CommonEditorOperations { editor.execute([ ChangeSelectionRequest( selectedNode.selectionBetween( + docSelection.extent.documentPath, wordNodeSelection.baseOffset, wordNodeSelection.extentOffset, ), @@ -217,16 +218,7 @@ class CommonEditorOperations { editor.execute([ ChangeSelectionRequest( - DocumentSelection( - base: DocumentPosition( - nodeId: document.first.id, - nodePosition: document.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: document.last.id, - nodePosition: document.last.endPosition, - ), - ), + document.selectAll(), SelectionChangeType.expandSelection, SelectionReason.userInteraction, ), @@ -297,7 +289,7 @@ class CommonEditorOperations { } final currentExtent = composer.selection!.extent; - final nodeId = currentExtent.nodeId; + final nodeId = currentExtent.documentPath.targetNodeId; final node = document.getNodeById(nodeId); if (node == null) { return false; @@ -335,7 +327,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -403,7 +395,7 @@ class CommonEditorOperations { } final currentExtent = composer.selection!.extent; - final nodeId = currentExtent.nodeId; + final nodeId = currentExtent.targetNodeId; final node = document.getNodeById(nodeId); if (node == null) { return false; @@ -444,7 +436,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -502,7 +494,7 @@ class CommonEditorOperations { } final currentExtent = composer.selection!.extent; - final nodeId = currentExtent.nodeId; + final nodeId = currentExtent.targetNodeId; final node = document.getNodeById(nodeId); if (node == null) { return false; @@ -535,7 +527,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -571,7 +563,7 @@ class CommonEditorOperations { } final currentExtent = composer.selection!.extent; - final nodeId = currentExtent.nodeId; + final nodeId = currentExtent.targetNodeId; final node = document.getNodeById(nodeId); if (node == null) { return false; @@ -604,7 +596,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -656,7 +648,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: newPosition, ); _updateSelectionExtent(position: newExtent, expandSelection: expand); @@ -683,10 +675,12 @@ class CommonEditorOperations { return false; } + // FIXME: Now that we have a tree document, what is the "beginning"? final firstNode = document.first; + final firstNodePath = document.getPathByNodeId(firstNode.id)!; if (expand) { - final currentExtentNode = document.getNodeById(composer.selection!.extent.nodeId); + final currentExtentNode = document.getNodeById(composer.selection!.extent.targetNodeId); if (currentExtentNode == null) { return false; } @@ -700,7 +694,7 @@ class CommonEditorOperations { DocumentSelection( base: composer.selection!.base, extent: DocumentPosition( - nodeId: firstNode.id, + documentPath: firstNodePath, nodePosition: firstNode.beginningPosition, ), ), @@ -716,7 +710,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: firstNode.id, + documentPath: firstNodePath, nodePosition: firstNode.beginningPosition, ), ), @@ -747,10 +741,12 @@ class CommonEditorOperations { return false; } + // FIXME: Now that we have a tree document, what is the "end"? final lastNode = document.last; + final lastNodePath = document.getPathByNodeId(lastNode.id)!; if (expand) { - final currentExtentNode = document.getNodeById(composer.selection!.extent.nodeId); + final currentExtentNode = document.getNodeById(composer.selection!.extent.targetNodeId); if (currentExtentNode == null) { return false; } @@ -761,10 +757,11 @@ class CommonEditorOperations { editor.execute([ ChangeSelectionRequest( + // FIXME: Change this to something like `document.end` DocumentSelection( base: composer.selection!.base, extent: DocumentPosition( - nodeId: lastNode.id, + documentPath: lastNodePath, nodePosition: lastNode.endPosition, ), ), @@ -778,9 +775,10 @@ class CommonEditorOperations { editor.execute([ ChangeSelectionRequest( + // FIXME: Change this to something like `document.end` DocumentSelection.collapsed( position: DocumentPosition( - nodeId: lastNode.id, + documentPath: lastNodePath, nodePosition: lastNode.endPosition, ), ), @@ -887,7 +885,7 @@ class CommonEditorOperations { final nodePosition = composer.selection!.extent.nodePosition as UpstreamDownstreamNodePosition; if (nodePosition.affinity == TextAffinity.upstream) { // The caret is sitting on the upstream edge of block-level content. - final nodeId = composer.selection!.extent.nodeId; + final nodeId = composer.selection!.extent.targetNodeId; if (!document.getNodeById(nodeId)!.isDeletable) { // The node is not deletable. Fizzle. @@ -909,9 +907,9 @@ class CommonEditorOperations { if (composer.selection!.extent.nodePosition is TextNodePosition) { final textPosition = composer.selection!.extent.nodePosition as TextNodePosition; - final text = (document.getNodeById(composer.selection!.extent.nodeId) as TextNode).text; + final text = (document.getNodeById(composer.selection!.extent.targetNodeId) as TextNode).text; if (textPosition.offset == text.length) { - final node = document.getNodeById(composer.selection!.extent.nodeId)!; + final node = document.getNodeById(composer.selection!.extent.targetNodeId)!; final nodeAfter = document.getNodeAfterById(node.id); if (nodeAfter is TextNode) { @@ -950,7 +948,7 @@ class CommonEditorOperations { return false; } - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.targetNodeId); if (node == null) { return false; } @@ -964,7 +962,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAfter.id, + documentPath: document.getPathByNodeId(nodeAfter.id)!, nodePosition: nodeAfter.beginningPosition, ), ), @@ -977,7 +975,7 @@ class CommonEditorOperations { } bool _mergeTextNodeWithDownstreamTextNode() { - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.targetNodeId); if (node == null) { return false; } @@ -1008,7 +1006,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + documentPath: document.getPathByNodeId(node.id)!, nodePosition: TextNodePosition(offset: firstNodeTextLength), ), ), @@ -1044,6 +1042,7 @@ class CommonEditorOperations { editor.execute([ DeleteContentRequest( documentRange: textNode.selectionBetween( + composer.selection!.extent.documentPath, currentTextOffset, nextCharacterOffset, ), @@ -1077,7 +1076,7 @@ class CommonEditorOperations { return true; } - final node = document.getNodeById(composer.selection!.extent.nodeId)!; + final node = document.getNodeById(composer.selection!.extent.targetNodeId)!; // If the caret is at the beginning of a list item, unindent the list item. if (node is ListItemNode && (composer.selection!.extent.nodePosition as TextNodePosition).offset == 0) { @@ -1088,7 +1087,7 @@ class CommonEditorOperations { final nodePosition = composer.selection!.extent.nodePosition as UpstreamDownstreamNodePosition; if (nodePosition.affinity == TextAffinity.downstream) { // The caret is sitting on the downstream edge of block-level content. - final nodeId = composer.selection!.extent.nodeId; + final nodeId = composer.selection!.extent.targetNodeId; if (!document.getNodeById(nodeId)!.isDeletable) { // The node is not deletable. Fizzle. @@ -1179,7 +1178,7 @@ class CommonEditorOperations { return false; } - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.targetNodeId); if (node == null) { return false; } @@ -1193,7 +1192,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1214,7 +1213,7 @@ class CommonEditorOperations { return false; } - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.targetNodeId); if (node == null) { return false; } @@ -1232,7 +1231,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1256,7 +1255,7 @@ class CommonEditorOperations { /// If there are non-deletable [BlockNode]s between the two [TextNode]s, /// the [BlockNode]s are ignored. bool mergeTextNodeWithUpstreamTextNode() { - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.targetNodeId); if (node == null) { return false; } @@ -1284,7 +1283,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAbove.id, + documentPath: document.getPathByNodeId(nodeAbove.id)!, nodePosition: TextNodePosition(offset: aboveParagraphLength), ), ), @@ -1310,13 +1309,14 @@ class CommonEditorOperations { return false; } - final textNode = document.getNode(composer.selection!.extent) as TextNode; + final textNodePath = composer.selection!.extent.documentPath; + final textNode = document.getNodeAtPath(textNodePath) as TextNode; final currentTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; final previousCharacterOffset = getCharacterStartBounds(textNode.text.toPlainText(), currentTextOffset); final newSelectionPosition = DocumentPosition( - nodeId: textNode.id, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: previousCharacterOffset), ); @@ -1324,6 +1324,7 @@ class CommonEditorOperations { editor.execute([ DeleteContentRequest( documentRange: textNode.selectionBetween( + textNodePath, currentTextOffset, previousCharacterOffset, ), @@ -1424,13 +1425,13 @@ class CommonEditorOperations { ? selection.base : selection.extent; final topNodePosition = topPosition.nodePosition; - final topNode = document.getNodeById(topPosition.nodeId)!; + final topNode = document.getNodeById(topPosition.targetNodeId)!; final bottomPosition = selectionAffinity == TextAffinity.downstream // ? selection.extent : selection.base; final bottomNodePosition = bottomPosition.nodePosition; - final bottomNode = document.getNodeById(bottomPosition.nodeId)!; + final bottomNode = document.getNodeById(bottomPosition.targetNodeId)!; final normalizedRange = selection.normalize(document); final nodes = document.getNodesInside(normalizedRange.start, normalizedRange.end); @@ -1438,7 +1439,7 @@ class CommonEditorOperations { DocumentPosition newSelectionPosition; - if (topPosition.nodeId != bottomPosition.nodeId) { + if (topPosition.documentPath != bottomPosition.documentPath) { if (topNodePosition == topNode.beginningPosition && bottomNodePosition == bottomNode.endPosition) { // All deletable nodes in the selection will be deleted. Assume that one of the // nodes will be retained and converted into a paragraph, if it's not @@ -1457,21 +1458,21 @@ class CommonEditorOperations { } newSelectionPosition = DocumentPosition( - nodeId: emptyParagraphId, + documentPath: document.getPathByNodeId(emptyParagraphId)!, nodePosition: const TextNodePosition(offset: 0), ); } else if (topNodePosition == topNode.beginningPosition) { // The top node will be deleted, but only part of the bottom node // will be deleted. newSelectionPosition = DocumentPosition( - nodeId: bottomNode.id, + documentPath: document.getPathByNodeId(bottomNode.id)!, nodePosition: bottomNode.beginningPosition, ); } else if (bottomNodePosition == bottomNode.endPosition) { // The bottom node will be deleted, but only part of the top node // will be deleted. newSelectionPosition = DocumentPosition( - nodeId: topNode.id, + documentPath: document.getPathByNodeId(topNode.id)!, nodePosition: topNodePosition, ); } else { @@ -1492,7 +1493,7 @@ class CommonEditorOperations { if (basePosition.nodePosition is UpstreamDownstreamNodePosition) { // Assume that the node was replace with an empty paragraph. newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + documentPath: document.getPathByNodeId(baseNode.id)!, nodePosition: const TextNodePosition(offset: 0), ); } else if (basePosition.nodePosition is TextNodePosition) { @@ -1500,7 +1501,7 @@ class CommonEditorOperations { final extentOffset = (extentPosition.nodePosition as TextNodePosition).offset; newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + documentPath: document.getPathByNodeId(baseNode.id)!, nodePosition: TextNodePosition(offset: min(baseOffset, extentOffset)), ); } else { @@ -1513,8 +1514,8 @@ class CommonEditorOperations { } void deleteNonSelectedNode(DocumentNode node) { - assert(composer.selection?.base.nodeId != node.id); - assert(composer.selection?.extent.nodeId != node.id); + assert(composer.selection?.base.targetNodeId != node.id); + assert(composer.selection?.extent.targetNodeId != node.id); editor.execute([DeleteNodeRequest(nodeId: node.id)]); } @@ -1671,7 +1672,7 @@ class CommonEditorOperations { insertBlockLevelNewline(); } - final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId)!; if (extentNode is! TextNode) { editorOpsLog .fine("Couldn't insert text because Super Editor doesn't know how to handle a node of type: $extentNode"); @@ -1723,7 +1724,7 @@ class CommonEditorOperations { editor.execute([InsertNewlineAtCaretRequest()]); } - final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId)!; if (extentNode is! TextNode) { editorOpsLog.fine( "Couldn't insert character because Super Editor doesn't know how to handle a node of type: $extentNode"); @@ -1790,8 +1791,8 @@ class CommonEditorOperations { } // Ensure that the entire selection sits within the same node. - final baseNode = document.getNodeById(composer.selection!.base.nodeId)!; - final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; + final baseNode = document.getNodeById(composer.selection!.base.targetNodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId)!; if (baseNode.id != extentNode.id) { editorOpsLog.finer("The selection spans multiple nodes. Can't insert block-level newline."); return false; @@ -1826,7 +1827,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1852,7 +1853,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1878,7 +1879,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1902,7 +1903,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1928,7 +1929,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1967,11 +1968,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final node = document.getNodeById(composer.selection!.base.nodeId); + final node = document.getNodeById(composer.selection!.base.targetNodeId); if (node is! ParagraphNode) { return false; } @@ -2003,11 +2004,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final node = document.getNodeById(composer.selection!.base.nodeId); + final node = document.getNodeById(composer.selection!.base.targetNodeId); if (node is! ParagraphNode) { return false; } @@ -2042,11 +2043,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final nodeId = composer.selection!.base.nodeId; + final nodeId = composer.selection!.base.targetNodeId; final node = document.getNodeById(nodeId); if (node is! ParagraphNode) { return false; @@ -2070,8 +2071,8 @@ class CommonEditorOperations { return false; } - final baseNode = document.getNodeById(composer.selection!.base.nodeId); - final extentNode = document.getNodeById(composer.selection!.extent.nodeId); + final baseNode = document.getNodeById(composer.selection!.base.targetNodeId); + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId); if (baseNode is! ListItemNode || extentNode is! ListItemNode) { return false; } @@ -2097,8 +2098,8 @@ class CommonEditorOperations { return false; } - final baseNode = document.getNodeById(composer.selection!.base.nodeId); - final extentNode = document.getNodeById(composer.selection!.extent.nodeId); + final baseNode = document.getNodeById(composer.selection!.base.targetNodeId); + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId); if (baseNode!.id != extentNode!.id) { return false; } @@ -2125,11 +2126,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final nodeId = composer.selection!.base.nodeId; + final nodeId = composer.selection!.base.targetNodeId; final node = document.getNodeById(nodeId); if (node is! TextNode) { return false; @@ -2155,11 +2156,11 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + if (composer.selection!.base.documentPath != composer.selection!.extent.documentPath) { return false; } - final nodeId = composer.selection!.base.nodeId; + final nodeId = composer.selection!.base.targetNodeId; final node = document.getNodeById(nodeId); if (node is! TextNode) { return false; @@ -2187,8 +2188,8 @@ class CommonEditorOperations { return false; } - final baseNode = document.getNodeById(composer.selection!.base.nodeId)!; - final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; + final baseNode = document.getNodeById(composer.selection!.base.targetNodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.targetNodeId)!; if (baseNode.id != extentNode.id) { return false; } @@ -2212,7 +2213,7 @@ class CommonEditorOperations { required DocumentSelection selection, }) { final extentPosition = selection.extent; - final extentNode = document.getNodeById(extentPosition.nodeId); + final extentNode = document.getNodeById(extentPosition.targetNodeId); return extentNode is TextNode; } @@ -2262,7 +2263,7 @@ class CommonEditorOperations { if (i == 0) { // This is the first node and it may be partially selected. - final baseSelectionPosition = selectedNode.id == documentSelection.base.nodeId + final baseSelectionPosition = selectedNode.id == documentSelection.base.documentPath ? documentSelection.base.nodePosition : documentSelection.extent.nodePosition; @@ -2275,7 +2276,7 @@ class CommonEditorOperations { ); } else if (i == selectedNodes.length - 1) { // This is the last node and it may be partially selected. - final nodePosition = selectedNode.id == documentSelection.base.nodeId + final nodePosition = selectedNode.id == documentSelection.base.documentPath ? documentSelection.base.nodePosition : documentSelection.extent.nodePosition; @@ -2409,7 +2410,7 @@ class PasteEditorCommand extends EditCommand { final document = context.document; final composer = context.find(Editor.composerKey); - final currentNodeWithSelection = document.getNodeById(_pastePosition.nodeId); + final currentNodeWithSelection = document.getNodeById(_pastePosition.targetNodeId); if (currentNodeWithSelection is! TextNode) { throw Exception('Can\'t handle pasting text within node of type: $currentNodeWithSelection'); } @@ -2448,7 +2449,7 @@ class PasteEditorCommand extends EditCommand { // The first line of pasted text was added to the selected paragraph. // Now, add all remaining pasted nodes to the document.. - DocumentNode previousNode = document.getNodeById(_pastePosition.nodeId)!; + DocumentNode previousNode = document.getNodeById(_pastePosition.targetNodeId)!; // ^ re-query the node where the first paragraph was pasted because nodes are immutable. for (final pastedNode in parsedContent.sublist(1)) { document.insertNodeAfter( @@ -2472,7 +2473,7 @@ class PasteEditorCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: pastedNode.id, + documentPath: document.getPathByNodeId(pastedNode.id)!, nodePosition: pastedNode.endPosition, ), ), @@ -2582,7 +2583,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { if (!selection.isCollapsed) { throw Exception("Tried to delete upstream character but the selection isn't collapsed."); } - if (document.getNodeById(selection.extent.nodeId) is! TextNode) { + if (document.getNodeById(selection.extent.targetNodeId) is! TextNode) { throw Exception("Tried to delete upstream character but the selected node isn't a TextNode."); } if (selection.isCollapsed && (selection.extent.nodePosition as TextNodePosition).offset <= 0) { @@ -2590,6 +2591,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { } final textNode = document.getNode(selection.extent) as TextNode; + final currentTextPath = selection.extent.documentPath; final currentTextOffset = (selection.extent.nodePosition as TextNodePosition).offset; final previousCharacterOffset = getCharacterStartBounds(textNode.text.toPlainText(), currentTextOffset); @@ -2599,6 +2601,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { ..executeCommand( DeleteContentCommand( documentRange: textNode.selectionBetween( + currentTextPath, currentTextOffset, previousCharacterOffset, ), @@ -2606,7 +2609,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { ) ..executeCommand( ChangeSelectionCommand( - textNode.selectionAt(previousCharacterOffset), + textNode.selectionAt(currentTextPath, previousCharacterOffset), SelectionChangeType.deleteContent, SelectionReason.userInteraction, ), @@ -2636,7 +2639,7 @@ class DeleteDownstreamCharacterCommand extends EditCommand { if (!selection.isCollapsed) { throw Exception("Tried to delete downstream character but the selection isn't collapsed."); } - if (document.getNodeById(selection.extent.nodeId) is! TextNode) { + if (document.getNodeById(selection.extent.targetNodeId) is! TextNode) { throw Exception("Tried to delete downstream character but the selected node isn't a TextNode."); } @@ -2653,6 +2656,7 @@ class DeleteDownstreamCharacterCommand extends EditCommand { executor.executeCommand( DeleteContentCommand( documentRange: textNode.selectionBetween( + selection.extent.documentPath, currentTextPositionOffset, nextCharacterOffset, ), diff --git a/super_editor/lib/src/default_editor/composer/composer_reactions.dart b/super_editor/lib/src/default_editor/composer/composer_reactions.dart index 8cdf5d0cc6..0042035451 100644 --- a/super_editor/lib/src/default_editor/composer/composer_reactions.dart +++ b/super_editor/lib/src/default_editor/composer/composer_reactions.dart @@ -128,7 +128,7 @@ class UpdateComposerTextStylesReaction extends EditReaction { return; } - final node = document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeAtPath(composer.selection!.extent.documentPath); if (node is! TextNode) { return; } diff --git a/super_editor/lib/src/default_editor/default_document_editor_reactions.dart b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart index 9e455f329f..d74ab89fe6 100644 --- a/super_editor/lib/src/default_editor/default_document_editor_reactions.dart +++ b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart @@ -73,13 +73,14 @@ class HeaderConversionReaction extends ParagraphPrefixConversionReaction { final prefixLength = match.length - 1; // -1 for the space on the end late Attribution headerAttribution = _getHeaderAttributionForLevel(prefixLength); + final paragraphPath = editContext.document.getPathByNodeId(paragraph.id)!; final paragraphPatternSelection = DocumentSelection( base: DocumentPosition( - nodeId: paragraph.id, + documentPath: paragraphPath, nodePosition: const TextNodePosition(offset: 0), ), extent: DocumentPosition( - nodeId: paragraph.id, + documentPath: paragraphPath, nodePosition: TextNodePosition(offset: paragraph.text.toPlainText().indexOf(" ") + 1), ), ); @@ -102,7 +103,7 @@ class HeaderConversionReaction extends ParagraphPrefixConversionReaction { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: paragraph.id, + documentPath: editContext.document.getPathByNodeId(paragraph.id)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -146,7 +147,7 @@ class UnorderedListItemConversionReaction extends ParagraphPrefixConversionReact ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: paragraph.id, + documentPath: editContext.document.getPathByNodeId(paragraph.id)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -218,7 +219,7 @@ class OrderedListItemConversionReaction extends ParagraphPrefixConversionReactio ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: paragraph.id, + documentPath: editContext.document.getPathByNodeId(paragraph.id)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -263,7 +264,7 @@ class BlockquoteConversionReaction extends ParagraphPrefixConversionReaction { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: paragraph.id, + documentPath: editContext.document.getPathByNodeId(paragraph.id)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -324,11 +325,12 @@ class HorizontalRuleConversionReaction extends EditReaction { // - Remove the dashes and the space. // - Insert a horizontal rule before the paragraph. // - Place caret at the start of the paragraph. + final paragraphPath = document.getPathByNodeId(paragraph.id)!; requestDispatcher.execute([ DeleteContentRequest( documentRange: DocumentRange( - start: DocumentPosition(nodeId: paragraph.id, nodePosition: const TextNodePosition(offset: 0)), - end: DocumentPosition(nodeId: paragraph.id, nodePosition: TextNodePosition(offset: match.length)), + start: DocumentPosition(documentPath: paragraphPath, nodePosition: const TextNodePosition(offset: 0)), + end: DocumentPosition(documentPath: paragraphPath, nodePosition: TextNodePosition(offset: match.length)), ), ), InsertNodeAtIndexRequest( @@ -340,7 +342,7 @@ class HorizontalRuleConversionReaction extends EditReaction { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: paragraph.id, + documentPath: paragraphPath, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -819,13 +821,14 @@ class LinkifyReaction extends EditReaction { range: rangeToUpdate, ); + final changeNodePath = document.getPathByNodeId(changedNodeId)!; final linkRange = DocumentRange( start: DocumentPosition( - nodeId: changedNodeId, + documentPath: changeNodePath, nodePosition: TextNodePosition(offset: rangeToUpdate.start), ), end: DocumentPosition( - nodeId: changedNodeId, + documentPath: changeNodePath, nodePosition: TextNodePosition(offset: rangeToUpdate.end + 1), ), ); @@ -1042,18 +1045,23 @@ class DashConversionReaction extends EditReaction { // A dash was inserted after another dash. // Convert the two dashes to an em-dash. + final insertionNodePath = document.getPathByNodeId(insertionNode.id)!; requestDispatcher.execute([ DeleteContentRequest( documentRange: DocumentRange( start: DocumentPosition( - nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset - 1)), + documentPath: insertionNodePath, + nodePosition: TextNodePosition(offset: dashInsertionEvent.offset - 1), + ), end: DocumentPosition( - nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset + 1)), + documentPath: insertionNodePath, + nodePosition: TextNodePosition(offset: dashInsertionEvent.offset + 1), + ), ), ), InsertTextRequest( documentPosition: DocumentPosition( - nodeId: insertionNode.id, + documentPath: insertionNodePath, nodePosition: TextNodePosition( offset: dashInsertionEvent.offset - 1, ), @@ -1064,7 +1072,7 @@ class DashConversionReaction extends EditReaction { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: insertionNode.id, + documentPath: insertionNodePath, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset), ), ), diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index 5df60030ea..40fe2863ac 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -420,11 +420,11 @@ class _DocumentMouseInteractorState extends State with ChangeSelectionRequest( DocumentSelection( base: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.upstream(), ), extent: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.downstream(), ), ), diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index f321c17e1d..6fcb8f8bbc 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -887,11 +887,11 @@ class _AndroidDocumentTouchInteractorState extends State final adjustedSelectionOffset = IosHeuristics.adjustTapOffset(text.toPlainText(), tapOffset); return DocumentPosition( - nodeId: docPosition.nodeId, + documentPath: docPosition.documentPath, nodePosition: TextNodePosition(offset: adjustedSelectionOffset), ); } @@ -807,11 +807,11 @@ class _IosDocumentTouchInteractorState extends State ChangeSelectionRequest( DocumentSelection( base: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.upstream(), ), extent: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.downstream(), ), ), diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart index ffca2c033a..521ea71474 100644 --- a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart @@ -451,7 +451,7 @@ ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ return ExecutionInstruction.continueExecution; } - final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + final node = editContext.document.getNodeAtPath(editContext.composer.selection!.extent.documentPath); if (node is! TextNode) { return ExecutionInstruction.continueExecution; } @@ -476,7 +476,7 @@ ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + documentPath: editContext.document.getPathByNodeId(nextNode.id)!, nodePosition: TextNodePosition(offset: currentParagraphLength), ), ), 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 420cafede1..e96a538a1e 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 @@ -316,8 +316,9 @@ class TextDeltasDocumentEditor { // After inserting a block level new line, the selection changes to another node. // Therefore, we need to update the insertion position. - insertionNode = document.getNodeById(selection.value!.extent.nodeId)!; - insertionPosition = DocumentPosition(nodeId: insertionNode.id, nodePosition: insertionNode.endPosition); + final insertionPath = selection.value!.extent.documentPath; + insertionNode = document.getNodeAtPath(insertionPath)!; + insertionPosition = DocumentPosition(documentPath: insertionPath, nodePosition: insertionNode.endPosition); } if (insertionNode is! TextNode || insertionPosition.nodePosition is! TextNodePosition) { 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 df0aaced1c..5d06b1e9b2 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 @@ -373,7 +373,7 @@ class DocumentImeSerializer { if (nodePath.nodeIds.length == 1) { // This is a single node - not a composite node. Return it as-is. return DocumentPosition( - nodeId: node.id, + documentPath: nodePath, nodePosition: contentNodePosition, ); } @@ -387,7 +387,7 @@ class DocumentImeSerializer { ); } return DocumentPosition( - nodeId: nodePath.nodeIds.first, + documentPath: NodePath([nodePath.nodeIds.first]), nodePosition: compositeNodePosition, ); } @@ -485,8 +485,10 @@ class DocumentImeSerializer { } if (nodePosition is CompositeNodePosition) { - final innerDocumentPosition = - DocumentPosition(nodeId: nodePosition.childNodeId, nodePosition: nodePosition.childNodePosition); + final innerDocumentPosition = DocumentPosition( + documentPath: docPosition.documentPath.addSubPath(nodePosition.childNodeId), + nodePosition: nodePosition.childNodePosition, + ); // Recursive call to create the IME text position for the content within the composite node. return _documentToImePosition(innerDocumentPosition); diff --git a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart index 4c0576ca84..924cce08f2 100644 --- a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart +++ b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart @@ -478,7 +478,7 @@ class KeyboardEditingToolbarOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: selectedNode.id, + documentPath: document.getPathByNodeId(selectedNode.id)!, nodePosition: const TextNodePosition(offset: 3), ), ), 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 f6f3ea8d51..a6e372bf4d 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 @@ -193,7 +193,7 @@ class _SingleColumnDocumentLayoutState extends State } final selectionAtOffset = DocumentPosition( - nodeId: _componentKeysToNodeIds[componentKey]!, + documentPath: widget.presenter.getPathToNode(_componentKeysToNodeIds[componentKey]!)!, nodePosition: componentPosition, ); editorLayoutLog.info(' - selection at offset: $selectionAtOffset'); @@ -455,11 +455,11 @@ class _SingleColumnDocumentLayoutState extends State editorLayoutLog.fine(' - the entire selection sits within a single node: $topNodeId'); return DocumentSelection( base: DocumentPosition( - nodeId: topNodeId, + documentPath: widget.presenter.getPathToNode(topNodeId)!, nodePosition: topNodeBasePosition, ), extent: DocumentPosition( - nodeId: bottomNodeId, + documentPath: widget.presenter.getPathToNode(bottomNodeId)!, nodePosition: topNodeExtentPosition, ), ); @@ -473,11 +473,11 @@ class _SingleColumnDocumentLayoutState extends State return DocumentSelection( base: DocumentPosition( - nodeId: isDraggingDown ? topNodeId : bottomNodeId, + documentPath: widget.presenter.getPathToNode(isDraggingDown ? topNodeId : bottomNodeId)!, nodePosition: isDraggingDown ? topNodeBasePosition : bottomNodeBasePosition, ), extent: DocumentPosition( - nodeId: isDraggingDown ? bottomNodeId : topNodeId, + documentPath: widget.presenter.getPathToNode(isDraggingDown ? bottomNodeId : topNodeId)!, nodePosition: isDraggingDown ? bottomNodeExtentPosition : topNodeExtentPosition, ), ); @@ -586,7 +586,7 @@ class _SingleColumnDocumentLayoutState extends State final component = componentKey.currentState as DocumentComponent; return DocumentPosition( - nodeId: _componentKeysToNodeIds[componentKey]!, + documentPath: widget.presenter.getPathToNode(_componentKeysToNodeIds[componentKey]!)!, nodePosition: component.getBeginningPosition(), ); } @@ -601,7 +601,7 @@ class _SingleColumnDocumentLayoutState extends State final component = componentKey.currentState as DocumentComponent; return DocumentPosition( - nodeId: _componentKeysToNodeIds[componentKey]!, + documentPath: widget.presenter.getPathToNode(_componentKeysToNodeIds[componentKey]!)!, nodePosition: component.getEndPosition(), ); } @@ -696,7 +696,7 @@ class _SingleColumnDocumentLayoutState extends State } return DocumentPosition( - nodeId: nodeId!, + documentPath: widget.presenter.getPathToNode(nodeId!)!, nodePosition: nodePosition, ); } 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 0b2eef54cf..4b0e215d76 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 @@ -104,6 +104,11 @@ class SingleColumnLayoutPresenter { } } + // TODO: check if this is the appropriate place for this method. I added this + // so that the document layout widget could report document positions for + // document components. + NodePath? getPathToNode(String nodeId) => _document.getPathByNodeId(nodeId); + void _assemblePipeline() { // Add all the phases that were provided by the client. for (int i = 0; i < _pipeline.length; i += 1) { diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index 73a6493809..36aab31892 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -1051,7 +1051,7 @@ class InsertNewlineInListItemAtCaretCommand extends BaseInsertNewlineAtCaretComm ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), diff --git a/super_editor/lib/src/default_editor/multi_node_editing.dart b/super_editor/lib/src/default_editor/multi_node_editing.dart index 54eec0fff9..841f1ad854 100644 --- a/super_editor/lib/src/default_editor/multi_node_editing.dart +++ b/super_editor/lib/src/default_editor/multi_node_editing.dart @@ -84,7 +84,7 @@ class PasteStructuredContentEditorCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: pastePosition.nodeId, + documentPath: pastePosition.documentPath, nodePosition: TextNodePosition( offset: (pastePosition.nodePosition as TextNodePosition).offset + pastedNode.text.length), ), @@ -116,7 +116,7 @@ class PasteStructuredContentEditorCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: pastedNode.id, + documentPath: pastePosition.documentPath, nodePosition: pastedNode.endPosition, ), ), @@ -182,7 +182,7 @@ class PasteStructuredContentEditorCommand extends EditCommand { executor.executeCommand( InsertAttributedTextCommand( documentPosition: DocumentPosition( - nodeId: downstreamSplitNode.id, + documentPath: document.getPathByNodeId(downstreamSplitNode.id)!, nodePosition: const TextNodePosition(offset: 0), ), // Only text nodes are merge-able, therefore we know that the last pasted node @@ -227,7 +227,7 @@ class PasteStructuredContentEditorCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: previousNode.id, + documentPath: document.getPathByNodeId(previousNode.id)!, nodePosition: previousNode.endPosition, ), ), @@ -421,7 +421,7 @@ class InsertNodeAtCaretCommand extends EditCommand { newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: selectedNodeId, + documentPath: document.getPathByNodeId(selectedNodeId)!, nodePosition: selectedNode.beginningPosition, ), ); @@ -436,7 +436,7 @@ class InsertNodeAtCaretCommand extends EditCommand { newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: selectedNode.id, + documentPath: document.getPathByNodeId(selectedNode.id)!, nodePosition: selectedNode.beginningPosition, ), ); @@ -458,7 +458,7 @@ class InsertNodeAtCaretCommand extends EditCommand { newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: emptyParagraph.id, + documentPath: document.getPathByNodeId(emptyParagraph.id)!, nodePosition: emptyParagraph.endPosition, ), ); @@ -488,7 +488,7 @@ class InsertNodeAtCaretCommand extends EditCommand { newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newParagraph.id, + documentPath: document.getPathByNodeId(newParagraph.id)!, nodePosition: newParagraph.beginningPosition, ), ); @@ -661,7 +661,7 @@ class ReplaceNodeWithEmptyParagraphWithCaretCommand extends EditCommand { executor.executeCommand(ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNode.id, + documentPath: document.getPathByNodeId(newNode.id)!, nodePosition: newNode.beginningPosition, ), ), @@ -714,7 +714,7 @@ class DeleteContentCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodes.first.id, + documentPath: document.getPathByNodeId(nodes.first.id)!, nodePosition: nodes.first.endPosition, ), ), @@ -1180,7 +1180,7 @@ class DeleteSelectionCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + documentPath: document.getPathByNodeId(node.id)!, nodePosition: node.endPosition, ), ), @@ -1369,7 +1369,7 @@ class ClearDocumentCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index e74f586528..971e915c18 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -750,7 +750,7 @@ class SplitParagraphCommand extends EditCommand { final oldComposingRegion = composer.composingRegion.value; final newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ); @@ -807,12 +807,15 @@ class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { return; } - final deletionPosition = DocumentPosition(nodeId: node.id, nodePosition: node.beginningPosition); + final document = context.document; + final deletionPosition = DocumentPosition( + documentPath: document.getPathByNodeId(node.id)!, + nodePosition: node.beginningPosition, + ); if (deletionPosition.nodePosition is! TextNodePosition) { return; } - final document = context.document; final composer = context.find(Editor.composerKey); final documentLayoutEditable = context.find(Editor.layoutKey); @@ -904,7 +907,7 @@ class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAbove.id, + documentPath: document.getPathByNodeId(nodeAbove.id)!, nodePosition: TextNodePosition(offset: aboveParagraphLength), ), ), @@ -939,7 +942,7 @@ class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1425,7 +1428,7 @@ ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ return ExecutionInstruction.continueExecution; } final newDocumentPosition = DocumentPosition( - nodeId: nodeAbove.id, + documentPath: editContext.document.getPathByNodeId(nodeAbove.id)!, nodePosition: nodeAbove.endPosition, ); diff --git a/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart index b6e9a8010d..6e6facdcc9 100644 --- a/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart +++ b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart @@ -146,7 +146,7 @@ class SuperEditorAddEmptyParagraphTapHandler extends ContentTapDelegate { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index acbd076551..401f85e5e3 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -677,7 +677,7 @@ class InsertNewlineInTaskAtCaretCommand extends BaseInsertNewlineAtCaretCommand ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -914,7 +914,7 @@ class SplitExistingTaskCommand extends EditCommand { final oldComposingRegion = composer.composingRegion.value; final newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newTaskNode.id, + documentPath: document.getPathByNodeId(newTaskNode.id)!, nodePosition: const TextNodePosition(offset: 0), ), ); diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 074bcc7c9c..73d863a21d 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -33,6 +33,32 @@ import 'text_tools.dart'; @immutable class TextNode extends DocumentNode { + static DocumentSelection selectionWithin(List nodePath, int base, int extent) { + return DocumentSelection( + base: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: TextNodePosition(offset: base), + ), + extent: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: TextNodePosition(offset: extent), + ), + ); + } + + /// A factory method for a collapsed [DocumentSelection] within a [TextNode] + /// at the given [nodePath], and the given [textOffset] within that node. + /// + /// This factory is provided as a convenience for less verbose code. + static DocumentSelection caretAt(List nodePath, int textOffset) { + return DocumentSelection.collapsed( + position: DocumentPosition( + documentPath: NodePath(nodePath), + nodePosition: TextNodePosition(offset: textOffset), + ), + ); + } + TextNode({ required this.id, required this.text, @@ -89,14 +115,14 @@ class TextNode extends DocumentNode { } /// Returns a [DocumentSelection] within this [TextNode] from [startIndex] to [endIndex]. - DocumentSelection selectionBetween(int startIndex, int endIndex) { + DocumentSelection selectionBetween(NodePath nodePath, int startIndex, int endIndex) { return DocumentSelection( base: DocumentPosition( - nodeId: id, + documentPath: nodePath, nodePosition: TextNodePosition(offset: startIndex), ), extent: DocumentPosition( - nodeId: id, + documentPath: nodePath, nodePosition: TextNodePosition(offset: endIndex), ), ); @@ -104,29 +130,29 @@ class TextNode extends DocumentNode { /// Returns a collapsed [DocumentSelection], positioned within this [TextNode] at the /// given [collapsedIndex]. - DocumentSelection selectionAt(int collapsedIndex) { + DocumentSelection selectionAt(NodePath nodePath, int collapsedIndex) { return DocumentSelection.collapsed( - position: positionAt(collapsedIndex), + position: positionAt(nodePath, collapsedIndex), ); } /// Returns a [DocumentPosition] within this [TextNode] at the given text [index]. - DocumentPosition positionAt(int index) { + DocumentPosition positionAt(NodePath nodePath, int index) { return DocumentPosition( - nodeId: id, + documentPath: nodePath, nodePosition: TextNodePosition(offset: index), ); } /// Returns a [DocumentRange] within this [TextNode] between [startIndex] and [endIndex]. - DocumentRange rangeBetween(int startIndex, int endIndex) { + DocumentRange rangeBetween(NodePath nodePath, int startIndex, int endIndex) { return DocumentRange( start: DocumentPosition( - nodeId: id, + documentPath: nodePath, nodePosition: TextNodePosition(offset: startIndex), ), end: DocumentPosition( - nodeId: id, + documentPath: nodePath, nodePosition: TextNodePosition(offset: endIndex), ), ); @@ -2030,7 +2056,7 @@ class InsertTextCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: textNode.id, + documentPath: document.getPathByNodeId(textNode.id)!, nodePosition: TextNodePosition( offset: textOffset + textToInsert.length, affinity: textPosition.affinity, @@ -2171,6 +2197,7 @@ class InsertNewlineInCodeBlockAtCaretCommand extends BaseInsertNewlineAtCaretCom // When inserting a newline after another newline, the existing // newline should be removed from the code block, and a new paragraph // should be inserted below the code block. + final document = context.document; if (caretNodePosition.offset == node.text.length && node.text.last == "\n") { // The caret is at the end of a code block, following another newline. // Remove the existing newline. @@ -2200,7 +2227,7 @@ class InsertNewlineInCodeBlockAtCaretCommand extends BaseInsertNewlineAtCaretCom ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -2213,7 +2240,7 @@ class InsertNewlineInCodeBlockAtCaretCommand extends BaseInsertNewlineAtCaretCom executor.executeCommand( InsertTextCommand( documentPosition: DocumentPosition( - nodeId: node.id, + documentPath: document.getPathByNodeId(node.id)!, nodePosition: node.endPosition, ), textToInsert: "\n", @@ -2295,7 +2322,7 @@ class DefaultInsertNewlineAtCaretCommand extends BaseInsertNewlineAtCaretCommand ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -2319,7 +2346,7 @@ class DefaultInsertNewlineAtCaretCommand extends BaseInsertNewlineAtCaretCommand ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -2356,7 +2383,7 @@ class DefaultInsertNewlineAtCaretCommand extends BaseInsertNewlineAtCaretCommand ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -2774,21 +2801,21 @@ DocumentPosition _getDocumentPositionAfterExpandedDeletion({ // node will be retained and converted into a paragraph, if it's not // already a paragraph. newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + documentPath: document.getPathByNodeId(baseNode.id)!, nodePosition: const TextNodePosition(offset: 0), ); } else if (topNodePosition == topNode.beginningPosition) { // The top node will be deleted, but only part of the bottom node // will be deleted. newSelectionPosition = DocumentPosition( - nodeId: bottomNode.id, + documentPath: document.getPathByNodeId(bottomNode.id)!, nodePosition: bottomNode.beginningPosition, ); } else if (bottomNodePosition == bottomNode.endPosition) { // The bottom node will be deleted, but only part of the top node // will be deleted. newSelectionPosition = DocumentPosition( - nodeId: topNode.id, + documentPath: document.getPathByNodeId(topNode.id)!, nodePosition: topNodePosition, ); } else { @@ -2809,7 +2836,7 @@ DocumentPosition _getDocumentPositionAfterExpandedDeletion({ if (basePosition.nodePosition is UpstreamDownstreamNodePosition) { // Assume that the node was replace with an empty paragraph. newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + documentPath: document.getPathByNodeId(baseNode.id)!, nodePosition: const TextNodePosition(offset: 0), ); } else if (basePosition.nodePosition is TextNodePosition) { @@ -2817,7 +2844,7 @@ DocumentPosition _getDocumentPositionAfterExpandedDeletion({ final extentOffset = (extentPosition.nodePosition as TextNodePosition).offset; newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + documentPath: document.getPathByNodeId(baseNode.id)!, nodePosition: TextNodePosition(offset: min(baseOffset, extentOffset)), ); } else { diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 7156b52ad3..bcae2cce54 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -253,6 +253,7 @@ class CancelComposingActionTagCommand extends EditCommand { executor.executeCommand( RemoveTextAttributionsCommand( documentRange: textNode!.selectionBetween( + extent.documentPath, composingToken.indexedTag.startOffset, composingToken.indexedTag.endOffset, ), @@ -262,6 +263,7 @@ class CancelComposingActionTagCommand extends EditCommand { executor.executeCommand( AddTextAttributionsCommand( documentRange: textNode.selectionBetween( + extent.documentPath, composingToken.indexedTag.startOffset, composingToken.indexedTag.startOffset + 1, ), @@ -346,6 +348,7 @@ class ActionTagComposingReaction extends EditReaction { continue; } + final nodePath = document.getPathByNodeId(change.nodeId)!; final node = document.getNodeById(change.nodeId); if (node is! TextNode) { continue; @@ -354,7 +357,7 @@ class ActionTagComposingReaction extends EditReaction { // The content in a TextNode changed. Check for the existence of any // out-of-sync cancelled tags and fix them. healChangeRequests.addAll( - _healCancelledTagsInTextNode(requestDispatcher, node), + _healCancelledTagsInTextNode(requestDispatcher, nodePath, node), ); } @@ -362,7 +365,8 @@ class ActionTagComposingReaction extends EditReaction { requestDispatcher.execute(healChangeRequests); } - List _healCancelledTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + List _healCancelledTagsInTextNode( + RequestDispatcher requestDispatcher, NodePath nodePath, TextNode node) { final cancelledTagRanges = node.text.getAttributionSpansInRange( attributionFilter: (a) => a == actionTagCancelledAttribution, range: SpanRange(0, node.text.length - 1), @@ -382,12 +386,12 @@ class ActionTagComposingReaction extends EditReaction { // This cancelled range includes more than just a trigger. Reduce it back // down to the trigger. final triggerIndex = cancelledText.indexOf(_tagRule.trigger); - addedRange = node.selectionBetween(triggerIndex, triggerIndex); + addedRange = node.selectionBetween(nodePath, triggerIndex, triggerIndex); } changeRequests.addAll([ RemoveTextAttributionsRequest( - documentRange: node.selectionBetween(range.start, range.end), + documentRange: node.selectionBetween(nodePath, range.start, range.end), attributions: {actionTagCancelledAttribution}, ), if (addedRange != null) // diff --git a/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart index 7cbdb5f9c4..2f2ce36874 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart @@ -360,10 +360,12 @@ class PatternTagReaction extends EditReaction { editorPatternTagsLog.fine( "Found a pattern tag around caret: '${tagAroundCaret.indexedTag.tag}' - surrounding it with an attribution: ${tagAroundCaret.indexedTag.startOffset} -> ${tagAroundCaret.indexedTag.endOffset}"); + final nodePath = document.getPathByNodeId(selectedNode.id)!; requestDispatcher.execute([ // Remove the old pattern tag attribution(s). RemoveTextAttributionsRequest( documentRange: selectedNode.selectionBetween( + nodePath, tagAroundCaret.indexedTag.startOffset, tagAroundCaret.indexedTag.endOffset, ), @@ -376,6 +378,7 @@ class PatternTagReaction extends EditReaction { // Add the new/updated pattern tag attribution. AddTextAttributionsRequest( documentRange: selectedNode.selectionBetween( + nodePath, tagAroundCaret.indexedTag.startOffset, tagAroundCaret.indexedTag.endOffset, ), @@ -412,12 +415,13 @@ class PatternTagReaction extends EditReaction { editorPatternTagsLog.info("Checking edited text nodes for back-to-back pattern tags that need to be split apart"); for (final textEdit in textEdits) { + final nodePath = document.getPathByNodeId(textEdit.nodeId)!; final node = document.getNodeById(textEdit.nodeId) as TextNode; - _splitBackToBackTagsInTextNode(requestDispatcher, node); + _splitBackToBackTagsInTextNode(requestDispatcher, nodePath, node); } } - void _splitBackToBackTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + void _splitBackToBackTagsInTextNode(RequestDispatcher requestDispatcher, NodePath nodePath, TextNode node) { final patternTags = node.text.getAttributionSpansByFilter( (attribution) => attribution is PatternTagAttribution, ); @@ -481,6 +485,7 @@ class PatternTagReaction extends EditReaction { for (final removal in spanRemovals) RemoveTextAttributionsRequest( documentRange: node.selectionBetween( + nodePath, removal.start, removal.end + 1, ), @@ -491,6 +496,7 @@ class PatternTagReaction extends EditReaction { for (final creation in spanCreations) AddTextAttributionsRequest( documentRange: node.selectionBetween( + nodePath, creation.start, creation.end + 1, ), @@ -536,6 +542,7 @@ class PatternTagReaction extends EditReaction { final document = editContext.document; final removeTagRequests = {}; for (final nodeId in nodesToInspect) { + final textNodePath = document.getPathByNodeId(nodeId)!; final textNode = document.getNodeById(nodeId) as TextNode; final allTags = textNode.text.getAttributionSpansInRange( attributionFilter: (attribution) => attribution is PatternTagAttribution, @@ -549,6 +556,7 @@ class PatternTagReaction extends EditReaction { removeTagRequests.add( RemoveTextAttributionsRequest( documentRange: textNode.selectionBetween( + textNodePath, tag.start, tag.end + 1, ), diff --git a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart index fc7e326e61..9eff844a5a 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart @@ -192,9 +192,11 @@ class FillInComposingUserTagCommand extends EditCommand { final base = selection.base; final extent = selection.extent; TagAroundPosition? composingToken; + late final NodePath textNodePath; TextNode? textNode; if (base.nodePosition is TextNodePosition) { + textNodePath = document.getPathByNodeId(selection.base.nodeId)!; textNode = document.getNodeById(selection.base.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( tagRule: _tagRule, @@ -205,6 +207,7 @@ class FillInComposingUserTagCommand extends EditCommand { ); } if (composingToken == null && extent.nodePosition is TextNodePosition) { + textNodePath = document.getPathByNodeId(selection.extent.nodeId)!; textNode = document.getNodeById(selection.extent.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( tagRule: _tagRule, @@ -228,6 +231,7 @@ class FillInComposingUserTagCommand extends EditCommand { executor.executeCommand( DeleteContentCommand( documentRange: textNode!.selectionBetween( + textNodePath, composingToken.indexedTag.startOffset, composingToken.indexedTag.endOffset, ), @@ -236,7 +240,7 @@ class FillInComposingUserTagCommand extends EditCommand { // Insert a committed stable tag. executor.executeCommand( InsertAttributedTextCommand( - documentPosition: textNode.positionAt(composingToken.indexedTag.startOffset), + documentPosition: textNode.positionAt(textNodePath, composingToken.indexedTag.startOffset), textToInsert: AttributedText( "${_tagRule.trigger}$_tag ", AttributedSpans( @@ -252,7 +256,7 @@ class FillInComposingUserTagCommand extends EditCommand { executor.executeCommand( ChangeSelectionCommand( // +1 for trigger symbol, +1 for space after the token - textNode.selectionAt(composingToken.indexedTag.startOffset + _tag.length + 2), + textNode.selectionAt(textNodePath, composingToken.indexedTag.startOffset + _tag.length + 2), SelectionChangeType.placeCaret, SelectionReason.contentChange, ), @@ -307,9 +311,11 @@ class CancelComposingStableTagCommand extends EditCommand { final base = selection.base; final extent = selection.extent; TagAroundPosition? composingToken; + late final NodePath textNodePath; TextNode? textNode; if (base.nodePosition is TextNodePosition) { + textNodePath = document.getPathByNodeId(selection.base.nodeId)!; textNode = document.getNodeById(selection.base.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( tagRule: _tagRule, @@ -320,6 +326,7 @@ class CancelComposingStableTagCommand extends EditCommand { ); } if (composingToken == null && extent.nodePosition is TextNodePosition) { + textNodePath = document.getPathByNodeId(selection.extent.nodeId)!; textNode = document.getNodeById(selection.extent.nodeId) as TextNode; composingToken = TagFinder.findTagAroundPosition( tagRule: _tagRule, @@ -341,6 +348,7 @@ class CancelComposingStableTagCommand extends EditCommand { executor.executeCommand( RemoveTextAttributionsCommand( documentRange: textNode!.selectionBetween( + textNodePath, composingToken.indexedTag.startOffset, composingToken.indexedTag.endOffset, ), @@ -350,6 +358,7 @@ class CancelComposingStableTagCommand extends EditCommand { executor.executeCommand( AddTextAttributionsCommand( documentRange: textNode.selectionBetween( + textNodePath, composingToken.indexedTag.startOffset, composingToken.indexedTag.startOffset + 1, ), @@ -426,7 +435,11 @@ class TagUserReaction extends EditReaction { // The content in a TextNode changed. Check for the existence of any // out-of-sync cancelled tags and fix them. healChangeRequests.addAll( - _healCancelledTagsInTextNode(requestDispatcher, node), + _healCancelledTagsInTextNode( + requestDispatcher, + document.getPathByNodeId(change.nodeId)!, + node, + ), ); } @@ -434,7 +447,8 @@ class TagUserReaction extends EditReaction { requestDispatcher.execute(healChangeRequests); } - List _healCancelledTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + List _healCancelledTagsInTextNode( + RequestDispatcher requestDispatcher, NodePath nodePath, TextNode node) { final cancelledTagRanges = node.text.getAttributionSpansInRange( attributionFilter: (a) => a == stableTagCancelledAttribution, range: SpanRange(0, node.text.length - 1), @@ -454,12 +468,12 @@ class TagUserReaction extends EditReaction { // This cancelled range includes more than just a trigger. Reduce it back // down to the trigger. final triggerIndex = cancelledText.indexOf(_tagRule.trigger); - addedRange = node.selectionBetween(triggerIndex, triggerIndex); + addedRange = node.selectionBetween(nodePath, triggerIndex, triggerIndex); } changeRequests.addAll([ RemoveTextAttributionsRequest( - documentRange: node.selectionBetween(range.start, range.end), + documentRange: node.selectionBetween(nodePath, range.start, range.end), attributions: {stableTagCancelledAttribution}, ), if (addedRange != null) // @@ -558,6 +572,7 @@ class TagUserReaction extends EditReaction { final removeTagRequests = {}; final deleteTagRequests = {}; for (final nodeId in nodesToInspect) { + final nodePath = document.getPathByNodeId(nodeId)!; final textNode = document.getNodeById(nodeId) as TextNode; // If a composing tag no longer contains a trigger ("@"), remove the attribution. @@ -576,7 +591,7 @@ class TagUserReaction extends EditReaction { removeTagRequests.add( RemoveTextAttributionsRequest( - documentRange: textNode.selectionBetween(tag.start, tag.end + 1), + documentRange: textNode.selectionBetween(nodePath, tag.start, tag.end + 1), attributions: {stableTagComposingAttribution}, ), ); @@ -649,7 +664,7 @@ class TagUserReaction extends EditReaction { deleteTagRequests.add( DeleteContentRequest( - documentRange: textNode.selectionBetween(deleteFrom, deleteTo), + documentRange: textNode.selectionBetween(nodePath, deleteFrom, deleteTo), ), ); } @@ -659,9 +674,11 @@ class TagUserReaction extends EditReaction { deleteTagRequests.add( ChangeSelectionRequest( DocumentSelection( - base: baseOffsetAfterDeletions >= 0 ? textNode.positionAt(baseOffsetAfterDeletions) : baseBeforeDeletions, + base: baseOffsetAfterDeletions >= 0 + ? textNode.positionAt(nodePath, baseOffsetAfterDeletions) + : baseBeforeDeletions, extent: extentOffsetAfterDeletions >= 0 - ? textNode.positionAt(extentOffsetAfterDeletions) + ? textNode.positionAt(nodePath, extentOffsetAfterDeletions) : extentBeforeDeletions, ), SelectionChangeType.placeCaret, @@ -700,6 +717,7 @@ class TagUserReaction extends EditReaction { } final document = editContext.document; + final selectedNodePath = selectionPosition.documentPath; final selectedNode = document.getNodeById(selectionPosition.nodeId); if (selectedNode is! TextNode) { // Tagging only happens in the middle of text. The selected content isn't text. Return. @@ -719,6 +737,7 @@ class TagUserReaction extends EditReaction { onUpdateComposingStableTag?.call( ComposingStableTag( selectedNode.rangeBetween( + selectedNodePath, existingComposingTag.indexedTag.startOffset + 1, existingComposingTag.indexedTag.endOffset, ), @@ -752,6 +771,7 @@ class TagUserReaction extends EditReaction { onUpdateComposingStableTag?.call( ComposingStableTag( selectedNode.rangeBetween( + selectedNodePath, // +1 to remove trigger symbol nonAttributedTagAroundCaret.indexedTag.startOffset + 1, nonAttributedTagAroundCaret.indexedTag.endOffset, @@ -763,6 +783,7 @@ class TagUserReaction extends EditReaction { requestDispatcher.execute([ AddTextAttributionsRequest( documentRange: selectedNode.selectionBetween( + selectedNodePath, nonAttributedTagAroundCaret.indexedTag.startOffset, nonAttributedTagAroundCaret.indexedTag.endOffset, ), @@ -822,6 +843,7 @@ class TagUserReaction extends EditReaction { final selection = composer.selection; for (final textNodeId in composingTagNodeCandidates) { editorStableTagsLog.fine("Checking node $textNodeId for composing tags to commit"); + final textNodePath = document.getPathByNodeId(textNodeId)!; final textNode = document.getNodeById(textNodeId) as TextNode; final allTags = TagFinder.findAllTagsInTextNode(textNode, _tagRule); final composingTags = @@ -832,7 +854,7 @@ class TagUserReaction extends EditReaction { if (selection == null || selection.extent.nodeId != textNodeId || selection.base.nodeId != textNodeId) { editorStableTagsLog .info("Committing tag because selection is null, or selection moved to different node: '$composingTag'"); - _commitTag(requestDispatcher, textNode, composingTag); + _commitTag(requestDispatcher, textNodePath, textNode, composingTag); continue; } @@ -841,7 +863,7 @@ class TagUserReaction extends EditReaction { (extentPosition.offset <= composingTag.startOffset || extentPosition.offset > composingTag.endOffset)) { editorStableTagsLog .info("Committing tag because the caret is out of range: '$composingTag', extent: $extentPosition"); - _commitTag(requestDispatcher, textNode, composingTag); + _commitTag(requestDispatcher, textNodePath, textNode, composingTag); continue; } @@ -850,10 +872,10 @@ class TagUserReaction extends EditReaction { } } - void _commitTag(RequestDispatcher requestDispatcher, TextNode textNode, IndexedTag tag) { + void _commitTag(RequestDispatcher requestDispatcher, NodePath nodePath, TextNode textNode, IndexedTag tag) { onUpdateComposingStableTag?.call(null); - final tagSelection = textNode.selectionBetween(tag.startOffset, tag.endOffset); + final tagSelection = textNode.selectionBetween(nodePath, tag.startOffset, tag.endOffset); requestDispatcher // Remove composing tag attribution. @@ -1186,6 +1208,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { }) { editorStableTagsLog.fine("Adjusting the caret position to avoid stable tags."); + final textNodePath = editContext.document.getPathByNodeId(textNode.id)!; final tagAroundCaret = _findTagAroundPosition( textNode.id, textNode.text, @@ -1212,11 +1235,11 @@ class AdjustSelectionAroundTagReaction extends EditReaction { case SelectionChangeType.alteredContent: case SelectionChangeType.deleteContent: // Move the caret to the nearest edge of the tag. - _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, textNode.id, tagAroundCaret); + _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, textNodePath, tagAroundCaret); break; case SelectionChangeType.pushCaret: // Move the caret to the side of the tag in the direction of push motion. - _pushCaretToOppositeTagEdge(editContext, requestDispatcher, selectionChangeEvent, textNode.id, tagAroundCaret); + _pushCaretToOppositeTagEdge(editContext, requestDispatcher, selectionChangeEvent, tagAroundCaret); break; case SelectionChangeType.placeExtent: case SelectionChangeType.pushExtent: @@ -1237,6 +1260,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { editorStableTagsLog.fine("Adjusting an expanded selection to avoid a partial stable tag selection."); final document = editContext.document; + final extentNodePath = document.getPathByNodeId(newCaret.nodeId)!; final extentNode = document.getNodeById(newCaret.nodeId); if (extentNode is! TextNode) { // The caret isn't sitting in text. Fizzle. @@ -1265,7 +1289,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { } // Move the caret to the nearest edge of the tag. - _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, extentNode.id, tagAroundCaret); + _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, extentNodePath, tagAroundCaret); break; case SelectionChangeType.pushExtent: if (tagAroundCaret == null) { @@ -1277,7 +1301,6 @@ class AdjustSelectionAroundTagReaction extends EditReaction { editContext, requestDispatcher, selectionChangeEvent, - extentNode.id, tagAroundCaret, expand: true, ); @@ -1349,7 +1372,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { void _moveCaretToNearestTagEdge( RequestDispatcher requestDispatcher, SelectionChangeEvent selectionChangeEvent, - String textNodeId, + NodePath textNodePath, TagAroundPosition tagAroundCaret, ) { DocumentSelection? newSelection; @@ -1362,7 +1385,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { // Move the caret to the start of the tag. newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: textNodeId, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: tagAroundCaret.indexedTag.startOffset), ), ); @@ -1370,7 +1393,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { // Move the caret to the end of the tag. newSelection = DocumentSelection.collapsed( position: DocumentPosition( - nodeId: textNodeId, + documentPath: textNodePath, nodePosition: TextNodePosition(offset: tagAroundCaret.indexedTag.endOffset), ), ); @@ -1389,7 +1412,6 @@ class AdjustSelectionAroundTagReaction extends EditReaction { EditContext editContext, RequestDispatcher requestDispatcher, SelectionChangeEvent selectionChangeEvent, - String textNodeId, TagAroundPosition tagAroundCaret, { bool expand = false, }) { @@ -1417,7 +1439,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { ? DocumentSelection( base: selectionChangeEvent.newSelection!.base, extent: DocumentPosition( - nodeId: selectionChangeEvent.newSelection!.extent.nodeId, + documentPath: editContext.document.getPathByNodeId(selectionChangeEvent.newSelection!.extent.nodeId)!, nodePosition: TextNodePosition( offset: textOffset, ), @@ -1425,7 +1447,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { ) : DocumentSelection.collapsed( position: DocumentPosition( - nodeId: selectionChangeEvent.newSelection!.extent.nodeId, + documentPath: editContext.document.getPathByNodeId(selectionChangeEvent.newSelection!.extent.nodeId)!, nodePosition: TextNodePosition( offset: textOffset, ), @@ -1466,7 +1488,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { DocumentPosition? newBasePosition; if (tagAroundBase != null) { newBasePosition = DocumentPosition( - nodeId: selection.base.nodeId, + documentPath: document.getPathByNodeId(selection.base.nodeId)!, nodePosition: selectionAffinity == TextAffinity.downstream // ? TextNodePosition(offset: tagAroundBase.indexedTag.startOffset) : TextNodePosition(offset: tagAroundBase.indexedTag.endOffset), @@ -1485,7 +1507,7 @@ class AdjustSelectionAroundTagReaction extends EditReaction { DocumentPosition? newExtentPosition; if (tagAroundExtent != null) { newExtentPosition = DocumentPosition( - nodeId: selection.extent.nodeId, + documentPath: document.getPathByNodeId(selection.extent.nodeId)!, nodePosition: selectionAffinity == TextAffinity.downstream // ? TextNodePosition(offset: tagAroundExtent.indexedTag.endOffset) : TextNodePosition(offset: tagAroundExtent.indexedTag.startOffset), diff --git a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart index 3144915559..9578ce9947 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart @@ -260,7 +260,15 @@ class TagRule { /// responsibility to monitor the attribution bounds and keep them in sync with the content. /// The [IndexedTag] data structure is a tool that makes such management easier. class IndexedTag { - const IndexedTag(this.tag, this.nodeId, this.startOffset); + IndexedTag( + this.tag, + String? nodeId, + this.startOffset, { + NodePath? nodePath, + }) : assert(nodeId != null || nodePath != null, "You must provide a nodeId or a nodePath"), + assert(nodeId == null || nodePath == null, "You can provide a nodeId or a nodePath, but not both"), + nodeId = nodeId ?? nodePath!.targetNodeId, + nodePath = nodePath ?? NodePath.forNode(nodeId!); /// The plain-text tag value. final Tag tag; @@ -268,17 +276,22 @@ class IndexedTag { /// The node ID of the [TextNode] that contains this tag. final String nodeId; + /// The path in the `Document` from the root to the node that contains this tag. + final NodePath nodePath; + /// The text offset of the trigger symbol for this tag within the given [TextNode]. final int startOffset; /// The fully-specified [DocumentPosition] associated with the tag's [startOffset]. - DocumentPosition get start => DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: startOffset)); + DocumentPosition get start => + DocumentPosition(documentPath: nodePath, nodePosition: TextNodePosition(offset: startOffset)); /// The text offset immediately after the final character in this tag, within the given [TextNode]. int get endOffset => startOffset + tag.raw.length; /// The fully-specified [DocumentPosition] associated with the tag's [endOffset]. - DocumentPosition get end => DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: endOffset)); + DocumentPosition get end => + DocumentPosition(documentPath: nodePath, nodePosition: TextNodePosition(offset: endOffset)); /// The [DocumentRange] from [start] to [end]. DocumentRange get range => DocumentRange(start: start, end: end); diff --git a/super_editor/lib/src/default_editor/text_tools.dart b/super_editor/lib/src/default_editor/text_tools.dart index d084487ed4..9063736f70 100644 --- a/super_editor/lib/src/default_editor/text_tools.dart +++ b/super_editor/lib/src/default_editor/text_tools.dart @@ -42,11 +42,11 @@ DocumentSelection? getWordSelection({ _log.log('getWordSelection', ' - word selection: $wordNodeSelection'); return DocumentSelection( base: DocumentPosition( - nodeId: docPosition.nodeId, + documentPath: docPosition.documentPath, nodePosition: wordNodeSelection.base, ), extent: DocumentPosition( - nodeId: docPosition.nodeId, + documentPath: docPosition.documentPath, nodePosition: wordNodeSelection.extent, ), ); @@ -105,11 +105,11 @@ DocumentSelection? getParagraphSelection({ return DocumentSelection( base: DocumentPosition( - nodeId: docPosition.nodeId, + documentPath: docPosition.documentPath, nodePosition: paragraphNodeSelection.base, ), extent: DocumentPosition( - nodeId: docPosition.nodeId, + documentPath: docPosition.documentPath, nodePosition: paragraphNodeSelection.extent, ), ); diff --git a/super_editor/lib/src/document_operations/selection_operations.dart b/super_editor/lib/src/document_operations/selection_operations.dart index c93bdaece3..ab6f819032 100644 --- a/super_editor/lib/src/document_operations/selection_operations.dart +++ b/super_editor/lib/src/document_operations/selection_operations.dart @@ -31,6 +31,7 @@ bool moveSelectionToNearestSelectableNode({ required DocumentNode startingNode, bool expand = false, }) { + NodePath? newNodePath; String? newNodeId; NodePosition? newPosition; @@ -38,6 +39,7 @@ bool moveSelectionToNearestSelectableNode({ final downstreamNode = _getDownstreamSelectableNodeAfter(document, documentLayoutResolver, startingNode); if (downstreamNode != null) { newNodeId = downstreamNode.id; + newNodePath = document.getPathByNodeId(newNodeId)!; final nextComponent = documentLayoutResolver().getComponentByNodeId(newNodeId); newPosition = nextComponent?.getBeginningPosition(); } @@ -47,6 +49,7 @@ bool moveSelectionToNearestSelectableNode({ final upstreamNode = _getUpstreamSelectableNodeBefore(document, documentLayoutResolver, startingNode); if (upstreamNode != null) { newNodeId = upstreamNode.id; + newNodePath = document.getPathByNodeId(newNodeId)!; final previousComponent = documentLayoutResolver().getComponentByNodeId(newNodeId); newPosition = previousComponent?.getBeginningPosition(); } @@ -57,7 +60,7 @@ bool moveSelectionToNearestSelectableNode({ } final newExtent = DocumentPosition( - nodeId: newNodeId, + documentPath: newNodePath!, nodePosition: newPosition, ); @@ -242,11 +245,11 @@ bool selectBlockAt(DocumentPosition position, ValueNotifier selection.value = DocumentSelection( base: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.upstream(), ), extent: DocumentPosition( - nodeId: position.nodeId, + documentPath: position.documentPath, nodePosition: const UpstreamDownstreamNodePosition.downstream(), ), ); @@ -279,6 +282,7 @@ void moveToNearestSelectableComponent( // interactor, because it's for read-only documents. Selection operations // should probably be moved to something outside of CommonOps DocumentNode startingNode = document.getNodeById(nodeId)!; + NodePath? newNodePath; String? newNodeId; NodePosition? newPosition; @@ -286,6 +290,7 @@ void moveToNearestSelectableComponent( final downstreamNode = _getDownstreamSelectableNodeAfter(document, () => documentLayout, startingNode); if (downstreamNode != null) { newNodeId = downstreamNode.id; + newNodePath = document.getPathByNodeId(newNodeId); final nextComponent = documentLayout.getComponentByNodeId(newNodeId); newPosition = nextComponent?.getBeginningPosition(); } @@ -295,6 +300,7 @@ void moveToNearestSelectableComponent( final upstreamNode = _getUpstreamSelectableNodeBefore(document, () => documentLayout, startingNode); if (upstreamNode != null) { newNodeId = upstreamNode.id; + newNodePath = document.getPathByNodeId(newNodeId); final previousComponent = documentLayout.getComponentByNodeId(newNodeId); newPosition = previousComponent?.getBeginningPosition(); } @@ -306,7 +312,7 @@ void moveToNearestSelectableComponent( selection.value = selection.value!.expandTo( DocumentPosition( - nodeId: newNodeId, + documentPath: newNodePath!, nodePosition: newPosition, ), ); @@ -325,6 +331,7 @@ bool moveCaretUpstream({ } final currentExtent = selection.extent; + final newNodePath = currentExtent.documentPath; final nodeId = currentExtent.nodeId; final node = document.getNodeById(nodeId); if (node == null) { @@ -356,7 +363,7 @@ bool moveCaretUpstream({ } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: newNodePath, nodePosition: newExtentNodePosition, ); @@ -408,7 +415,7 @@ bool moveCaretDownstream({ return false; } - String newExtentNodeId = nodeId; + NodePath newExtentNodePath = currentExtent.documentPath; NodePosition? newExtentNodePosition = extentComponent.movePositionRight(currentExtent.nodePosition, movementModifier); if (newExtentNodePosition == null) { @@ -421,7 +428,7 @@ bool moveCaretDownstream({ return false; } - newExtentNodeId = nextNode.id; + newExtentNodePath = document.getPathByNodeId(nextNode.id)!; final nextComponent = documentLayout.getComponentByNodeId(nextNode.id); if (nextComponent == null) { throw Exception('Could not find component in document layout for the downstream node with ID: ${nextNode.id}'); @@ -430,7 +437,7 @@ bool moveCaretDownstream({ } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: newExtentNodePath, nodePosition: newExtentNodePosition, ); @@ -484,17 +491,17 @@ bool moveCaretUp({ return false; } - String newExtentNodeId = nodeId; + NodePath newExtentNodePath = currentExtent.documentPath; NodePosition? newExtentNodePosition = extentComponent.movePositionUp(currentExtent.nodePosition); if (newExtentNodePosition == null) { // Move to next node final nextNode = _getUpstreamSelectableNodeBefore(document, () => documentLayout, node); if (nextNode != null) { - newExtentNodeId = nextNode.id; + newExtentNodePath = document.getPathByNodeId(nextNode.id)!; final nextComponent = documentLayout.getComponentByNodeId(nextNode.id); if (nextComponent == null) { - editorOpsLog.shout("Tried to obtain non-existent component by node id: $newExtentNodeId"); + editorOpsLog.shout("Tried to obtain non-existent component by node id: $newExtentNodePath"); return false; } final offsetToMatch = extentComponent.getOffsetForPosition(currentExtent.nodePosition); @@ -507,7 +514,7 @@ bool moveCaretUp({ } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: newExtentNodePath, nodePosition: newExtentNodePosition, ); @@ -561,17 +568,17 @@ bool moveCaretDown({ return false; } - String newExtentNodeId = nodeId; + NodePath newExtentNodePath = currentExtent.documentPath; NodePosition? newExtentNodePosition = extentComponent.movePositionDown(currentExtent.nodePosition); if (newExtentNodePosition == null) { // Move to next node final nextNode = _getDownstreamSelectableNodeAfter(document, () => documentLayout, node); if (nextNode != null) { - newExtentNodeId = nextNode.id; + newExtentNodePath = document.getPathByNodeId(nextNode.id)!; final nextComponent = documentLayout.getComponentByNodeId(nextNode.id); if (nextComponent == null) { - editorOpsLog.shout("Tried to obtain non-existent component by node id: $newExtentNodeId"); + editorOpsLog.shout("Tried to obtain non-existent component by node id: $newExtentNodePath"); return false; } final offsetToMatch = extentComponent.getOffsetForPosition(currentExtent.nodePosition); @@ -584,7 +591,7 @@ bool moveCaretDown({ } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: newExtentNodePath, nodePosition: newExtentNodePosition, ); @@ -606,17 +613,7 @@ bool selectAll(Document document, ValueNotifier selection) { return false; } - selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: document.first.id, - nodePosition: document.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: document.last.id, - nodePosition: document.last.endPosition, - ), - ); - + selection.value = document.selectAll(); return true; } diff --git a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart index e782a003cd..1e691379ba 100644 --- a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart +++ b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart @@ -62,6 +62,7 @@ class _AttributionBoundsState extends ContentLayerState(selection), isEmpty); }); @@ -117,16 +90,7 @@ void main() { ); // Create a selection for the word "with"; - const selection = DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 5), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 9), - ), - ); + final selection = TextNode.selectionWithin(["1"], 5, 9); expect(document.getAttributionsByType(selection), isEmpty); }); @@ -151,16 +115,7 @@ void main() { ); // Create a selection for the word "with"; - const selection = DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 5), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 9), - ), - ); + final selection = TextNode.selectionWithin(["1"], 5, 9); expect(document.getAttributionsByType(selection), isEmpty); }); @@ -185,16 +140,7 @@ void main() { ); // Create a selection for the word "with"; - const selection = DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 5), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 9), - ), - ); + final selection = TextNode.selectionWithin(["1"], 5, 9); expect(document.getAttributionsByType(selection), {const FontSizeAttribution(14)}); }); diff --git a/super_editor/test/super_editor/infrastructure/document_selection_test.dart b/super_editor/test/super_editor/infrastructure/document_selection_test.dart index 9ac6d4c320..c796a7f980 100644 --- a/super_editor/test/super_editor/infrastructure/document_selection_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_selection_test.dart @@ -5,7 +5,10 @@ void main() { group("Document selection", () { group("selects upstream position", () { test("when the positions are the same", () { - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); expect( _testDoc.selectUpstreamPosition(position, position), position, @@ -13,8 +16,14 @@ void main() { }); test("when the positions are in the same node", () { - const position1 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); - const position2 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)); + final position1 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); + final position2 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), + ); expect( _testDoc.selectUpstreamPosition(position1, position2), position1, @@ -26,8 +35,14 @@ void main() { }); test("when the positions are in different nodes", () { - const position1 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); - const position2 = DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)); + final position1 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); + final position2 = DocumentPosition( + documentPath: NodePath.forNode("2"), + nodePosition: const TextNodePosition(offset: 0), + ); expect( _testDoc.selectUpstreamPosition(position1, position2), position1, @@ -41,7 +56,10 @@ void main() { group("selects downstream position", () { test("when the positions are the same", () { - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); expect( _testDoc.selectDownstreamPosition(position, position), position, @@ -49,8 +67,14 @@ void main() { }); test("when the positions are in the same node", () { - const position1 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); - const position2 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)); + final position1 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); + final position2 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), + ); expect( _testDoc.selectDownstreamPosition(position1, position2), position2, @@ -62,8 +86,14 @@ void main() { }); test("when the positions are in different nodes", () { - const position1 = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); - const position2 = DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)); + final position1 = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); + final position2 = DocumentPosition( + documentPath: NodePath.forNode("2"), + nodePosition: const TextNodePosition(offset: 0), + ); expect( _testDoc.selectDownstreamPosition(position1, position2), position2, @@ -77,112 +107,125 @@ void main() { group("knows if it contains a position", () { test("when the selection is collapsed", () { - const selection = DocumentSelection.collapsed( - position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0))); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); + final selection = TextNode.caretAt(["1"], 0); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), + ); expect(_testDoc.doesSelectionContainPosition(selection, position), false); }); test("when the selection is within one node and contains the position", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 2)), - ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 2)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = TextNode.selectionWithin(["1"], 0, 2); + final upstreamSelection = TextNode.selectionWithin(["1"], 2, 0); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), ); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), true); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), true); }); test("when the selection is within one node and the position sits before selection", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 2)), + final downstreamSelection = TextNode.selectionWithin(["1"], 1, 2); + final upstreamSelection = TextNode.selectionWithin(["1"], 2, 1); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 2)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), - ); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), false); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), false); }); test("when the selection is within one node and the position sits after selection", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), - ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = TextNode.selectionWithin(["1"], 0, 1); + final upstreamSelection = TextNode.selectionWithin(["1"], 1, 0); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 2), ); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 2)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), false); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), false); }); test("when the selection is across two nodes and contains the position", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + final upstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), + ); + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), ); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), true); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), true); }); test("when the selection is across two nodes and the position comes before the selection", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), - extent: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 1)), + extent: + DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), + ); + final upstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 1)), ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 1)), + final position = DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ); - const position = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), false); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), false); }); test("when the selection is across two nodes and the position comes after the selection", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + final upstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("2"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), + ); + final position = DocumentPosition( + documentPath: NodePath.forNode("2"), + nodePosition: const TextNodePosition(offset: 1), ); - const position = DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 1)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), false); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), false); }); test("when the selection is across three nodes and the position is in the middle", () { - const downstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), + final downstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("3"), nodePosition: const TextNodePosition(offset: 0)), + ); + final upstreamSelection = DocumentSelection( + base: DocumentPosition(documentPath: NodePath.forNode("3"), nodePosition: const TextNodePosition(offset: 0)), + extent: + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 0)), ); - const upstreamSelection = DocumentSelection( - base: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), - extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + final position = DocumentPosition( + documentPath: NodePath.forNode("2"), + nodePosition: const TextNodePosition(offset: 0), ); - const position = DocumentPosition(nodeId: "2", nodePosition: TextNodePosition(offset: 0)); expect(_testDoc.doesSelectionContainPosition(downstreamSelection, position), true); expect(_testDoc.doesSelectionContainPosition(upstreamSelection, position), true); diff --git a/super_editor/test/super_editor/infrastructure/editor_test.dart b/super_editor/test/super_editor/infrastructure/editor_test.dart index 569692cc70..244eab7455 100644 --- a/super_editor/test/super_editor/infrastructure/editor_test.dart +++ b/super_editor/test/super_editor/infrastructure/editor_test.dart @@ -25,12 +25,7 @@ void main() { test('executes a single command', () { final editorPieces = _createStandardEditor( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); List? changeLog; editorPieces.editor.addListener(FunctionalEditListener((changeList) { @@ -48,12 +43,7 @@ void main() { test('executes a series of commands', () { final editorPieces = _createStandardEditor( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); int changeLogCount = 0; int changeEventCount = 0; @@ -82,12 +72,7 @@ void main() { final document = MutableDocument.empty(); final composer = MutableDocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); final editor = Editor( editables: { @@ -149,12 +134,7 @@ void main() { final document = MutableDocument.empty("1"); final composer = MutableDocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); final editor = Editor( editables: { @@ -173,9 +153,9 @@ void main() { editor.execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ), textToInsert: "H", attributions: const {}, @@ -190,12 +170,7 @@ void main() { final document = MutableDocument.empty("1"); final composer = MutableDocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); final editor = Editor( @@ -227,7 +202,7 @@ void main() { requestDispatcher.execute([ InsertTextRequest( documentPosition: DocumentPosition( - nodeId: insertEEvent.nodeId, + documentPath: NodePath.forNode(insertEEvent.nodeId), nodePosition: TextNodePosition(offset: insertEEvent.offset + 1), // +1 for "e" ), textToInsert: "ll", @@ -241,9 +216,9 @@ void main() { editor ..execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ), textToInsert: "H", attributions: const {}, @@ -251,9 +226,9 @@ void main() { ]) ..execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 1), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), ), textToInsert: "e", attributions: const {}, @@ -261,9 +236,9 @@ void main() { ]) ..execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 4), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 4), ), textToInsert: "o", attributions: const {}, @@ -278,12 +253,7 @@ void main() { final document = MutableDocument.empty("1"); final composer = MutableDocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); final editor = Editor( @@ -316,7 +286,7 @@ void main() { requestDispatcher.execute([ InsertTextRequest( documentPosition: DocumentPosition( - nodeId: insertHEvent.nodeId, + documentPath: NodePath.forNode(insertHEvent.nodeId), nodePosition: TextNodePosition(offset: insertHEvent.offset), ), textToInsert: "e", @@ -346,9 +316,9 @@ void main() { editor.execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ), textToInsert: "H", attributions: const {}, @@ -362,12 +332,7 @@ void main() { final document = MutableDocument.empty("1"); final composer = MutableDocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); int reactionRunCount = 0; @@ -389,17 +354,17 @@ void main() { // Insert "e" after "H". requestDispatcher.execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 1), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 1), ), textToInsert: "e", attributions: {}, ), InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 2), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 2), ), textToInsert: "l", attributions: {}, @@ -411,9 +376,9 @@ void main() { editor.execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ), textToInsert: "H", attributions: const {}, @@ -426,33 +391,23 @@ void main() { test('inserts character at caret', () { final editorPieces = _createStandardEditor( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); editorPieces.editor ..execute([ InsertTextRequest( - documentPosition: const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), + documentPosition: DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 0), ), textToInsert: 'H', attributions: const {}, ), ]) ..execute([ - const ChangeSelectionRequest( - DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 1), - ), - ), + ChangeSelectionRequest( + TextNode.caretAt(["1"], 1), SelectionChangeType.placeCaret, "test", ), @@ -463,23 +418,13 @@ void main() { expect(editorPieces.composer.selection, isNotNull); expect( editorPieces.composer.selection, - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 1), - ), - ), + TextNode.caretAt(["1"], 1), ); }); test('inserts new paragraph node at caret', () { final editorPieces = _createStandardEditor( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); int changeLogCount = 0; int changeEventCount = 0; @@ -518,12 +463,7 @@ void main() { test('moves a document node to a higher index', () { final editorPieces = _createStandardEditor( initialDocument: longTextDoc(), - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); int changeLogCount = 0; @@ -564,12 +504,7 @@ void main() { test('moves a document node to a lower index', () { final editorPieces = _createStandardEditor( initialDocument: longTextDoc(), - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), + initialSelection: TextNode.caretAt(["1"], 0), ); int changeLogCount = 0; @@ -710,7 +645,7 @@ class _ExpandingCommand extends EditCommand { executor.executeCommand( InsertTextCommand( documentPosition: DocumentPosition( - nodeId: paragraph.id, + documentPath: NodePath.forNode(paragraph.id), nodePosition: TextNodePosition(offset: paragraph.text.length), ), textToInsert: diff --git a/super_editor/test/super_editor/infrastructure/mutable_document_test.dart b/super_editor/test/super_editor/infrastructure/mutable_document_test.dart index 92ebb40b4a..9f4bf224dd 100644 --- a/super_editor/test/super_editor/infrastructure/mutable_document_test.dart +++ b/super_editor/test/super_editor/infrastructure/mutable_document_test.dart @@ -15,23 +15,23 @@ void main() { // Try to get an upstream range. final range = document.getRangeBetween( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 20)), - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 10)), + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 20)), + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 10)), ); // Ensure the range is upstream. expect( range.start, - const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 10), + DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 10), ), ); expect( range.end, - const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 20), + DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 20), ), ); }); @@ -48,23 +48,23 @@ void main() { // Try to get an upstream range. final range = document.getRangeBetween( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 10)), - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 20)), + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 10)), + DocumentPosition(documentPath: NodePath.forNode("1"), nodePosition: const TextNodePosition(offset: 20)), ); // Ensure the range is upstream. expect( range.start, - const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 10), + DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 10), ), ); expect( range.end, - const DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 20), + DocumentPosition( + documentPath: NodePath.forNode("1"), + nodePosition: const TextNodePosition(offset: 20), ), ); }); diff --git a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart index 19f01956c6..4138667252 100644 --- a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart @@ -198,15 +198,16 @@ void main() { // Press the upstream drag handle and drag it downstream until "Lorem|" to collapse the selection. final gesture = await tester.pressDownOnUpstreamMobileHandle(); - await gesture.moveBy(SuperEditorInspector.findDeltaBetweenCharactersInTextNode('1', 0, 5)); + await gesture.moveBy(SuperEditorInspector.findDeltaBetweenCharactersInTextNode(NodePath.forNode('1'), 0, 5)); await tester.pump(); // Ensure that the selection collapsed. expect( SuperEditorInspector.findDocumentSelection(), selectionEquivalentTo( - const DocumentSelection.collapsed( - position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + DocumentSelection.collapsed( + position: + DocumentPosition(documentPath: NodePath.forNode('1'), nodePosition: const TextNodePosition(offset: 5)), ), ), ); @@ -214,7 +215,7 @@ void main() { // Find the rectangle for the selected character. final documentLayout = SuperEditorInspector.findDocumentLayout(); final selectedPositionRect = documentLayout.getRectForPosition( - const DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + DocumentPosition(documentPath: NodePath.forNode('1'), nodePosition: const TextNodePosition(offset: 5)), )!; // Ensure that the drag handles are visible and in the correct location. diff --git a/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart index e69b7a4975..da1f7c2c72 100644 --- a/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart @@ -86,11 +86,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 6), ), ), @@ -138,11 +138,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 6), ), ), @@ -167,11 +167,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 10), ), ), @@ -209,11 +209,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordDolorStart), ), ), @@ -230,11 +230,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordIpsumStart), ), ), @@ -279,11 +279,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 21), ), ), @@ -330,11 +330,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 21), ), ), @@ -359,11 +359,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 19), ), ), @@ -398,11 +398,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordAdipiscingStart), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordTemporEnd), ), ), @@ -419,11 +419,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordAdipiscingStart), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: _wordIncididuntEnd), ), ), @@ -465,8 +465,8 @@ void main() { expect( SuperEditorInspector.findDocumentSelection(), const DocumentSelection( - base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), - extent: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.downstream()), + base: DocumentPosition(documentPath: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(documentPath: '1', nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); @@ -483,9 +483,9 @@ void main() { expect( SuperEditorInspector.findDocumentSelection(), const DocumentSelection( - base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + base: DocumentPosition(documentPath: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition( - nodeId: "2", + documentPath: "2", nodePosition: TextNodePosition(offset: 5), ), ), @@ -527,8 +527,8 @@ void main() { expect( SuperEditorInspector.findDocumentSelection(), const DocumentSelection( - base: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.upstream()), - extent: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + base: DocumentPosition(documentPath: '2', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(documentPath: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); @@ -545,9 +545,9 @@ void main() { expect( SuperEditorInspector.findDocumentSelection(), const DocumentSelection( - base: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + base: DocumentPosition(documentPath: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 0), ), ), @@ -593,11 +593,11 @@ void main() { selectionEquivalentTo( const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 21), ), ), @@ -639,11 +639,11 @@ void main() { selectionEquivalentTo( const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 21), ), ), @@ -670,11 +670,11 @@ void main() { selectionEquivalentTo( const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 12), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 19), ), ), @@ -715,11 +715,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 6), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), ), @@ -759,11 +759,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 6), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), ), @@ -785,11 +785,11 @@ void main() { SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 10), ), extent: DocumentPosition( - nodeId: "1", + documentPath: "1", nodePosition: TextNodePosition(offset: 17), ), ), @@ -837,16 +837,7 @@ Future _pumpAppWithLongText(WidgetTester tester) async { .pump(); } -const _wordConsecteturSelection = DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 28), - ), - extent: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 39), - ), -); +final _wordConsecteturSelection = TextNode.selectionWithin(["1"], 28, 39); const _wordIpsumStart = 6; // ignore: unused_element @@ -854,28 +845,18 @@ const _wordIpsumEnd = 11; const _wordDolorStart = 12; const _wordDolorEnd = 17; -const _wordDolorSelection = DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: _wordDolorStart), - ), - extent: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: _wordDolorEnd), - ), +final _wordDolorSelection = TextNode.selectionWithin( + ["1"], + _wordDolorStart, + _wordDolorEnd, ); const _wordAdipiscingStart = 40; const _wordAdipiscingEnd = 50; -const _wordAdipiscingSelection = DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: _wordAdipiscingStart), - ), - extent: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), - ), +final _wordAdipiscingSelection = TextNode.selectionWithin( + ["1"], + _wordAdipiscingStart, + _wordAdipiscingEnd, ); // ignore: unused_element