diff --git a/super_editor/clones/quill/lib/editor/code_component.dart b/super_editor/clones/quill/lib/editor/code_component.dart index 1a878fb141..e44c93edf6 100644 --- a/super_editor/clones/quill/lib/editor/code_component.dart +++ b/super_editor/clones/quill/lib/editor/code_component.dart @@ -5,7 +5,11 @@ class FeatherCodeComponentBuilder implements ComponentBuilder { const FeatherCodeComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/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/components/demo_text_with_hint.dart b/super_editor/example/lib/demos/components/demo_text_with_hint.dart index 527590a19d..8878769436 100644 --- a/super_editor/example/lib/demos/components/demo_text_with_hint.dart +++ b/super_editor/example/lib/demos/components/demo_text_with_hint.dart @@ -146,7 +146,11 @@ class HeaderWithHintComponentBuilder implements ComponentBuilder { const HeaderWithHintComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This component builder can work with the standard paragraph view model. // We'll defer to the standard paragraph component builder to create it. return null; diff --git a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart index 64983b8095..55fcecef29 100644 --- a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart +++ b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart @@ -71,7 +71,11 @@ class UnselectableHrComponentBuilder implements ComponentBuilder { const UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/example/lib/demos/demo_animated_task_height.dart b/super_editor/example/lib/demos/demo_animated_task_height.dart index bb06d9671b..76a6a1d59b 100644 --- a/super_editor/example/lib/demos/demo_animated_task_height.dart +++ b/super_editor/example/lib/demos/demo_animated_task_height.dart @@ -72,7 +72,11 @@ class AnimatedTaskComponentBuilder implements ComponentBuilder { const AnimatedTaskComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/example/lib/demos/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/in_the_lab/feature_composite_nodes.dart b/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart new file mode 100644 index 0000000000..68cb70b2e3 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart @@ -0,0 +1,301 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class CompositeNodesDemo extends StatefulWidget { + const CompositeNodesDemo({super.key}); + + @override + State createState() => _CompositeNodesDemoState(); +} + +class _CompositeNodesDemoState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createInitialDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: darkModeStyles, + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + componentBuilders: [ + _BannerComponentBuilder(), + ...defaultComponentBuilders, + ], + ), + ); + } +} + +MutableDocument _createInitialDocument() { + return MutableDocument( + nodes: [ + ParagraphNode(id: "1.1", text: AttributedText("Paragraph before the first level of embedding.")), + CompositeDocumentNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + CompositeDocumentNode("3", [ + ParagraphNode(id: "3.1", text: AttributedText("This paragraph is in the 3rd level of document.")), + ]), + ParagraphNode(id: "2.3", text: AttributedText("Paragraph after the second level of embedding.")), + ]), + ParagraphNode(id: "1.3", text: AttributedText("Paragraph after the first level of embedding.")), + ], + ); +} + +class _BannerComponentBuilder implements ComponentBuilder { + _BannerComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + if (node is! CompositeDocumentNode) { + return null; + } + + print("Creating a composite view model (${node.id}) with ${node.nodeCount} child nodes"); + final childViewModels = []; + for (final childNode in node.nodes) { + print(" - Creating view model for child node: $childNode"); + SingleColumnLayoutComponentViewModel? viewModel; + for (final builder in componentBuilders) { + viewModel = builder.createViewModel(document, childNode, componentBuilders); + if (viewModel != null) { + break; + } + } + + print(" - view model: $viewModel"); + if (viewModel != null) { + childViewModels.add(viewModel); + } + } + + return CompositeViewModel( + nodeId: node.id, + node: node, + childViewModels: childViewModels, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! CompositeViewModel) { + return null; + } + print( + "Composite builder - createComponent() - with ${componentViewModel.childViewModels.length} child view models"); + + final childComponentIds = []; + final childComponents = []; + for (final childViewModel in componentViewModel.childViewModels) { + print("Creating component for child view model: $childViewModel"); + final childContext = SingleColumnDocumentComponentContext( + context: componentContext.context, + componentKey: GlobalKey(), + componentBuilders: componentContext.componentBuilders, + ); + Widget? component; + for (final builder in componentContext.componentBuilders) { + component = builder.createComponent(childContext, childViewModel); + if (component != null) { + break; + } + } + + print(" - component: $component"); + if (component != null) { + childComponentIds.add(childViewModel.nodeId); + childComponents.add(component); + } + } + + return _BannerComponent( + key: componentContext.componentKey, + node: componentViewModel.node, + childComponentIds: childComponentIds, + childComponents: childComponents, + ); + } +} + +class _BannerComponent extends StatefulWidget { + const _BannerComponent({ + super.key, + required this.node, + required this.childComponentIds, + required this.childComponents, + }); + + final CompositeDocumentNode node; + final List childComponentIds; + final List childComponents; + + @override + State<_BannerComponent> createState() => _BannerComponentState(); +} + +class _BannerComponentState extends State<_BannerComponent> with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return widget.node.beginningPosition; + } + + @override + NodePosition getBeginningPositionNearX(double x) { + // TODO: implement getBeginningPositionNearX + throw UnimplementedError(); + } + + @override + NodePosition getEndPosition() { + return widget.node.endPosition; + } + + @override + NodePosition getEndPositionNearX(double x) { + // TODO: implement getEndPositionNearX + throw UnimplementedError(); + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + return widget.node.computeSelection(base: nodePosition, extent: nodePosition); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + // TODO: implement getDesiredCursorAtOffset + throw UnimplementedError(); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + // TODO: implement getEdgeForPosition + throw UnimplementedError(); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + // TODO: implement getOffsetForPosition + throw UnimplementedError(); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + print("Looking for position in composite component at local offset: $localOffset"); + final compositeBox = context.findRenderObject() as RenderBox; + for (int i = 0; i < widget.childComponents.length; i += 1) { + final childComponent = widget.childComponents[i]; + print("Component widget: ${childComponent} - key: ${childComponent.key}"); + final componentKey = childComponent.key as GlobalKey; + final component = componentKey.currentState as DocumentComponent; + final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; + final componentLocalOffset = componentBox.localToGlobal(Offset.zero, ancestor: compositeBox); + final offsetInComponent = localOffset - componentLocalOffset; + final positionInComponent = component.getPositionAtOffset(offsetInComponent); + if (positionInComponent != null) { + print("Found position in component! - ${widget.childComponentIds[i]} - $positionInComponent"); + return CompositeNodePosition( + compositeNodeId: widget.node.id, + childNodeId: widget.childComponentIds[i], + childNodePosition: positionInComponent, + ); + } + } + + return null; + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + // TODO: implement getRectForPosition + throw UnimplementedError(); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + // TODO: implement getRectForSelection + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + // TODO: implement getSelectionBetween + throw UnimplementedError(); + } + + @override + NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { + // TODO: implement getSelectionInRange + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionOfEverything() { + // TODO: implement getSelectionOfEverything + throw UnimplementedError(); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + // TODO: implement movePositionDown + throw UnimplementedError(); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionLeft + throw UnimplementedError(); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionRight + throw UnimplementedError(); + } + + @override + NodePosition? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey), + color: Colors.grey.withOpacity(0.1), + ), + padding: const EdgeInsets.all(24), + child: Column( + children: widget.childComponents, + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart index 73c75a9250..355b6fb022 100644 --- a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart +++ b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart @@ -181,8 +181,13 @@ class SpellingErrorParagraphComponentBuilder implements ComponentBuilder { final UnderlineStyle underlineStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - final viewModel = ParagraphComponentBuilder().createViewModel(document, node) as ParagraphComponentViewModel?; + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + final viewModel = + ParagraphComponentBuilder().createViewModel(document, node, componentBuilders) as ParagraphComponentViewModel?; if (viewModel == null) { return null; } diff --git a/super_editor/example/lib/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/main.dart b/super_editor/example/lib/main.dart index 8b0a94dd58..075fbd676c 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -15,6 +15,7 @@ import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; import 'package:example/demos/in_the_lab/feature_action_tags.dart'; +import 'package:example/demos/in_the_lab/feature_composite_nodes.dart'; import 'package:example/demos/in_the_lab/feature_ios_native_context_menu.dart'; import 'package:example/demos/in_the_lab/feature_pattern_tags.dart'; import 'package:example/demos/in_the_lab/feature_stable_tags.dart'; @@ -333,6 +334,13 @@ final _menu = <_MenuGroup>[ return const NativeIosContextMenuFeatureDemo(); }, ), + _MenuItem( + icon: Icons.account_tree, + title: 'Embedded Components', + pageBuilder: (context) { + return const CompositeNodesDemo(); + }, + ), ], ), _MenuGroup( diff --git a/super_editor/example/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/example_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart index b7908122cf..9c3f2c8531 100644 --- a/super_editor/example_perf/lib/demos/rebuild_demo.dart +++ b/super_editor/example_perf/lib/demos/rebuild_demo.dart @@ -74,7 +74,11 @@ class AnimatedTaskComponentBuilder implements ComponentBuilder { const AnimatedTaskComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 5c4f932fb6..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; - /// Node-specific representation of a position. + /// Returns the ID of the node that this path points to. + String get targetNodeId => documentPath.targetNodeId; + + /// 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)'; } } @@ -481,6 +517,341 @@ 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.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 + String toString() => "[NodePath] - ${nodeIds.join(" > ")}"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodePath && + runtimeType == other.runtimeType && + const DeepCollectionEquality().equals(nodeIds, other.nodeIds); + + @override + int get hashCode => const ListEquality().hash(nodeIds); +} + +/// A [DocumentNode] that contains other [DocumentNode]s in a hierarchy. +/// +/// [CompositeDocumentNode]s can contain more [CompositeDocumentNode]s. There's no +/// logical restriction on the depth of this hierarchy. However, the effect of a multi-level +/// hierarchy depends on the document layout and components that are used within a +/// given editor. +class CompositeDocumentNode extends DocumentNode { + CompositeDocumentNode(this.id, this._nodes) + : assert(_nodes.isNotEmpty, "CompositeDocumentNode's must contain at least 1 inner node."); + + @override + final String id; + + Iterable get nodes => List.from(_nodes); + final List _nodes; + + int get nodeCount => _nodes.length; + + @override + NodePosition get beginningPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.first.id, + childNodePosition: _nodes.first.beginningPosition, + ); + + @override + NodePosition get endPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.last.id, + childNodePosition: _nodes.last.endPosition, + ); + + @override + NodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { + if (position1 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position1 but received a ${position1.runtimeType}'); + } + if (position2 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position2 but received a ${position2.runtimeType}'); + } + + if (position1.compositeNodeId != id) { + throw Exception( + "Expected position1 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position1.compositeNodeId}"); + } + if (position2.compositeNodeId != id) { + throw Exception( + "Expected position2 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position2.compositeNodeId}"); + } + + final position1NodeIndex = _findNodeIndexById(position1.childNodeId); + if (position1NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position1.childNodeId}"); + } + + final position2NodeIndex = _findNodeIndexById(position2.childNodeId); + if (position2NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position2.childNodeId}"); + } + + if (position1NodeIndex <= position2NodeIndex) { + return position1; + } else { + return position2; + } + } + + @override + NodePosition selectDownstreamPosition(NodePosition position1, NodePosition position2) { + if (position1 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position1 but received a ${position1.runtimeType}'); + } + if (position2 is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for position2 but received a ${position2.runtimeType}'); + } + + if (position1.compositeNodeId != id) { + throw Exception( + "Expected position1 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position1.compositeNodeId}"); + } + if (position2.compositeNodeId != id) { + throw Exception( + "Expected position2 to refer to this CompositeNodePosition with ID '$id' but instead we received a position with node ID: ${position2.compositeNodeId}"); + } + + final position1NodeIndex = _findNodeIndexById(position1.childNodeId); + if (position1NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position1.childNodeId}"); + } + + final position2NodeIndex = _findNodeIndexById(position2.childNodeId); + if (position2NodeIndex == null) { + throw Exception("Couldn't find a child node with ID: ${position2.childNodeId}"); + } + + if (position1NodeIndex < position2NodeIndex) { + return position2; + } else { + return position1; + } + } + + @override + CompositeNodeSelection computeSelection({required NodePosition base, required NodePosition extent}) { + if (base is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for base but received a ${base.runtimeType}'); + } + if (extent is! CompositeNodePosition) { + throw Exception('Expected a CompositeNodePosition for extent but received a ${extent.runtimeType}'); + } + + return CompositeNodeSelection(base: base, extent: extent); + } + + @override + bool containsPosition(Object position) { + // Composite nodes don't have a node position type. This query doesn't apply. + throw UnimplementedError(); + } + + int? _findNodeIndexById(String childNodeId) { + for (int i = 0; i < _nodes.length; i += 1) { + if (_nodes[i].id == childNodeId) { + return i; + } + } + + return null; + } + + @override + String? copyContent(NodeSelection selection) { + if (selection is! CompositeNodeSelection) { + return null; + } + + if (selection.base.compositeNodeId != id) { + return null; + } + + final baseNodeIndex = _findNodeIndexById(selection.base.childNodeId); + if (baseNodeIndex == null) { + return null; + } + + final extentNodeIndex = _findNodeIndexById(selection.extent.childNodeId); + if (extentNodeIndex == null) { + return null; + } + + if (baseNodeIndex == extentNodeIndex) { + // The selection sits entirely within a single node. Copy partial content + // from that node. + final childNode = _nodes[extentNodeIndex]; + final childSelection = childNode.computeSelection( + base: selection.base.childNodePosition, + extent: selection.extent.childNodePosition, + ); + return childNode.copyContent(childSelection); + } + + // The selection spans some number of nodes. Collate content from all of those nodes. + final buffer = StringBuffer(); + if (baseNodeIndex < extentNodeIndex) { + // The selection is in natural order. Grab content starting at the base + // position, all the way to the extent position. + final startNode = _nodes[baseNodeIndex]; + buffer.writeln(startNode.copyContent( + startNode.computeSelection(base: selection.base.childNodePosition, extent: startNode.endPosition), + )); + + for (int i = baseNodeIndex + 1; i < extentNodeIndex; i += 1) { + final node = _nodes[i]; + buffer.writeln( + node.copyContent( + node.computeSelection(base: node.beginningPosition, extent: node.endPosition), + ), + ); + } + + final endNode = _nodes[extentNodeIndex]; + buffer.write(endNode.copyContent( + endNode.computeSelection(base: endNode.beginningPosition, extent: selection.extent.childNodePosition), + )); + } else { + // The selection is in reverse order. Grab content starting at the extent + // position, all the way to the base position. + final startNode = _nodes[extentNodeIndex]; + buffer.writeln(startNode.copyContent( + startNode.computeSelection(base: selection.extent.childNodePosition, extent: startNode.endPosition), + )); + + for (int i = extentNodeIndex + 1; i < baseNodeIndex; i += 1) { + final node = _nodes[i]; + buffer.writeln( + node.copyContent( + node.computeSelection(base: node.beginningPosition, extent: node.endPosition), + ), + ); + } + + final endNode = _nodes[baseNodeIndex]; + buffer.write(endNode.copyContent( + endNode.computeSelection(base: endNode.beginningPosition, extent: selection.base.childNodePosition), + )); + } + + return buffer.toString(); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return copy(); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return copy(); + } + + DocumentNode copy() { + return CompositeDocumentNode(id, List.from(_nodes)); + } + + @override + String toString() => "[CompositeNode] - $_nodes"; +} + +/// A selection within a single [CompositeDocumentNode]. +class CompositeNodeSelection implements NodeSelection { + const CompositeNodeSelection({ + required this.base, + required this.extent, + }); + + final CompositeNodePosition base; + final CompositeNodePosition extent; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositeNodeSelection && + runtimeType == other.runtimeType && + base == other.base && + extent == other.extent; + + @override + int get hashCode => base.hashCode ^ extent.hashCode; +} + +/// A [NodePosition] for a [CompositeDocumentNode], which is a node that contains +/// other nodes in a node hierarchy. +class CompositeNodePosition implements NodePosition { + const CompositeNodePosition({ + required this.compositeNodeId, + required this.childNodeId, + required this.childNodePosition, + }); + + final String compositeNodeId; + final String childNodeId; + final NodePosition childNodePosition; + + @override + bool isEquivalentTo(NodePosition other) { + if (other is! CompositeNodePosition) { + return false; + } + + if (compositeNodeId != other.compositeNodeId || childNodeId != other.childNodeId) { + return false; + } + + return childNodePosition.isEquivalentTo(other.childNodePosition); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositeNodePosition && + runtimeType == other.runtimeType && + compositeNodeId == other.compositeNodeId && + childNodeId == other.childNodeId && + childNodePosition == other.childNodePosition; + + @override + int get hashCode => compositeNodeId.hashCode ^ childNodeId.hashCode ^ childNodePosition.hashCode; +} + /// Marker interface for a selection within a [DocumentNode]. abstract class NodeSelection { // marker interface diff --git a/super_editor/lib/src/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/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 7a176f2795..e3fdd1d21d 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -25,7 +25,11 @@ class BlockquoteComponentBuilder implements ComponentBuilder { const BlockquoteComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/lib/src/default_editor/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 102be8c8c9..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; @@ -439,11 +431,12 @@ 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(); } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -501,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; @@ -534,7 +527,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -570,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; @@ -603,7 +596,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newExtentNodeId, + documentPath: document.getPathByNodeId(newExtentNodeId)!, nodePosition: newExtentNodePosition, ); @@ -655,7 +648,7 @@ class CommonEditorOperations { } final newExtent = DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: newPosition, ); _updateSelectionExtent(position: newExtent, expandSelection: expand); @@ -682,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; } @@ -699,7 +694,7 @@ class CommonEditorOperations { DocumentSelection( base: composer.selection!.base, extent: DocumentPosition( - nodeId: firstNode.id, + documentPath: firstNodePath, nodePosition: firstNode.beginningPosition, ), ), @@ -715,7 +710,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: firstNode.id, + documentPath: firstNodePath, nodePosition: firstNode.beginningPosition, ), ), @@ -746,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; } @@ -760,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, ), ), @@ -777,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, ), ), @@ -886,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. @@ -908,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) { @@ -949,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; } @@ -963,7 +962,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAfter.id, + documentPath: document.getPathByNodeId(nodeAfter.id)!, nodePosition: nodeAfter.beginningPosition, ), ), @@ -976,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; } @@ -1007,7 +1006,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + documentPath: document.getPathByNodeId(node.id)!, nodePosition: TextNodePosition(offset: firstNodeTextLength), ), ), @@ -1043,6 +1042,7 @@ class CommonEditorOperations { editor.execute([ DeleteContentRequest( documentRange: textNode.selectionBetween( + composer.selection!.extent.documentPath, currentTextOffset, nextCharacterOffset, ), @@ -1076,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) { @@ -1087,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. @@ -1178,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; } @@ -1192,7 +1192,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1213,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; } @@ -1231,7 +1231,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1255,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; } @@ -1283,7 +1283,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAbove.id, + documentPath: document.getPathByNodeId(nodeAbove.id)!, nodePosition: TextNodePosition(offset: aboveParagraphLength), ), ), @@ -1309,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), ); @@ -1323,6 +1324,7 @@ class CommonEditorOperations { editor.execute([ DeleteContentRequest( documentRange: textNode.selectionBetween( + textNodePath, currentTextOffset, previousCharacterOffset, ), @@ -1423,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); @@ -1437,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 @@ -1456,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 { @@ -1491,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) { @@ -1499,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 { @@ -1512,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)]); } @@ -1670,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"); @@ -1722,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"); @@ -1789,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; @@ -1825,7 +1827,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1851,7 +1853,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1877,7 +1879,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1901,7 +1903,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1927,7 +1929,7 @@ class CommonEditorOperations { ChangeSelectionRequest( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -1966,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; } @@ -2002,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; } @@ -2041,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; @@ -2069,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; } @@ -2096,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; } @@ -2124,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; @@ -2154,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; @@ -2186,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; } @@ -2211,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; } @@ -2261,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; @@ -2274,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; @@ -2408,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'); } @@ -2447,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( @@ -2471,7 +2473,7 @@ class PasteEditorCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: pastedNode.id, + documentPath: document.getPathByNodeId(pastedNode.id)!, nodePosition: pastedNode.endPosition, ), ), @@ -2581,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) { @@ -2589,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); @@ -2598,6 +2601,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { ..executeCommand( DeleteContentCommand( documentRange: textNode.selectionBetween( + currentTextPath, currentTextOffset, previousCharacterOffset, ), @@ -2605,7 +2609,7 @@ class DeleteUpstreamCharacterCommand extends EditCommand { ) ..executeCommand( ChangeSelectionCommand( - textNode.selectionAt(previousCharacterOffset), + textNode.selectionAt(currentTextPath, previousCharacterOffset), SelectionChangeType.deleteContent, SelectionReason.userInteraction, ), @@ -2635,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."); } @@ -2652,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/composite_component.dart b/super_editor/lib/src/default_editor/composite_component.dart new file mode 100644 index 0000000000..426809c10f --- /dev/null +++ b/super_editor/lib/src/default_editor/composite_component.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; + +class CompositeComponentBuilder implements ComponentBuilder { + const CompositeComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + if (node is! CompositeDocumentNode) { + return null; + } + + print("Creating a composite view model (${node.id}) with ${node.nodeCount} child nodes"); + final childViewModels = []; + for (final childNode in node.nodes) { + print(" - Creating view model for child node: $childNode"); + SingleColumnLayoutComponentViewModel? viewModel; + for (final builder in componentBuilders) { + viewModel = builder.createViewModel(document, childNode, componentBuilders); + if (viewModel != null) { + break; + } + } + + print(" - view model: $viewModel"); + if (viewModel != null) { + childViewModels.add(viewModel); + } + } + + return CompositeViewModel( + nodeId: node.id, + node: node, + childViewModels: childViewModels, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! CompositeViewModel) { + return null; + } + print( + "Composite builder - createComponent() - with ${componentViewModel.childViewModels.length} child view models"); + + final childComponents = []; + for (final childViewModel in componentViewModel.childViewModels) { + print("Creating component for child view model: $childViewModel"); + final childContext = SingleColumnDocumentComponentContext( + context: componentContext.context, + componentKey: GlobalKey(), + componentBuilders: componentContext.componentBuilders, + ); + Widget? component; + for (final builder in componentContext.componentBuilders) { + component = builder.createComponent(childContext, childViewModel); + if (component != null) { + break; + } + } + + print(" - component: $component"); + if (component != null) { + childComponents.add(component); + } + } + + return CompositeComponent( + key: componentContext.componentKey, + node: componentViewModel.node, + childComponents: childComponents, + ); + } +} + +class CompositeViewModel extends SingleColumnLayoutComponentViewModel { + CompositeViewModel({ + required super.nodeId, + required this.node, + super.maxWidth, + super.padding = EdgeInsets.zero, + required this.childViewModels, + }); + + final CompositeDocumentNode node; + final List childViewModels; + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + + // Forward styles to our children. + for (final child in childViewModels) { + child.applyStyles(styles); + } + } + + @override + SingleColumnLayoutComponentViewModel copy() { + return CompositeViewModel( + nodeId: nodeId, + node: node, + maxWidth: maxWidth, + padding: padding, + childViewModels: List.from(childViewModels), + ); + } +} + +class CompositeComponent extends StatefulWidget { + const CompositeComponent({ + super.key, + required this.node, + required this.childComponents, + }); + + final CompositeDocumentNode node; + final List childComponents; + + @override + State createState() => _CompositeComponentState(); +} + +class _CompositeComponentState extends State with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return widget.node.beginningPosition; + } + + @override + NodePosition getBeginningPositionNearX(double x) { + // TODO: implement getBeginningPositionNearX + throw UnimplementedError(); + } + + @override + NodePosition getEndPosition() { + return widget.node.endPosition; + } + + @override + NodePosition getEndPositionNearX(double x) { + // TODO: implement getEndPositionNearX + throw UnimplementedError(); + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + return widget.node.computeSelection(base: nodePosition, extent: nodePosition); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + // TODO: implement getDesiredCursorAtOffset + throw UnimplementedError(); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + // TODO: implement getEdgeForPosition + throw UnimplementedError(); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + // TODO: implement getOffsetForPosition + throw UnimplementedError(); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + // TODO: implement getPositionAtOffset + throw UnimplementedError(); + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + // TODO: implement getRectForPosition + throw UnimplementedError(); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + // TODO: implement getRectForSelection + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + // TODO: implement getSelectionBetween + throw UnimplementedError(); + } + + @override + NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { + // TODO: implement getSelectionInRange + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionOfEverything() { + // TODO: implement getSelectionOfEverything + throw UnimplementedError(); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + // TODO: implement movePositionDown + throw UnimplementedError(); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionLeft + throw UnimplementedError(); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionRight + throw UnimplementedError(); + } + + @override + NodePosition? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey), + color: Colors.grey.withOpacity(0.1), + ), + padding: const EdgeInsets.all(24), + child: Column( + children: widget.childComponents, + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/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 f2b1f23b91..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) { @@ -464,13 +465,15 @@ class TextDeltasDocumentEditor { final bottomImeToDocTextRange = TextRange(start: imeNewlineIndex + 1, end: newImeValue.text.length); // Update mapping from Document nodes to IME ranges. - _serializedDoc.docTextNodesToImeRanges[originNode.id] = topImeToDocTextRange; - _serializedDoc.docTextNodesToImeRanges[newNode.id] = bottomImeToDocTextRange; + // FIXME: Don't assume that every node is a top-level node + _serializedDoc.docTextNodesToImeRanges[NodePath.forNode(originNode.id)] = topImeToDocTextRange; + _serializedDoc.docTextNodesToImeRanges[NodePath.forNode(newNode.id)] = bottomImeToDocTextRange; // Remove old mapping from IME TextRange to Document node. - late final MapEntry oldImeToDoc; + late final MapEntry oldImeToDoc; for (final entry in _serializedDoc.imeRangesToDocTextNodes.entries) { - if (entry.value != originNode.id) { + // FIXME: Don't assume that every node is a top-level node + if (entry.value != NodePath.forNode(originNode.id)) { continue; } @@ -480,8 +483,9 @@ class TextDeltasDocumentEditor { _serializedDoc.imeRangesToDocTextNodes.remove(oldImeToDoc.key); // Update and add mapping from IME TextRanges to Document nodes. - _serializedDoc.imeRangesToDocTextNodes[topImeToDocTextRange] = originNode.id; - _serializedDoc.imeRangesToDocTextNodes[bottomImeToDocTextRange] = newNode.id; + // FIXME: Don't assume that every node is a top-level node + _serializedDoc.imeRangesToDocTextNodes[topImeToDocTextRange] = NodePath.forNode(originNode.id); + _serializedDoc.imeRangesToDocTextNodes[bottomImeToDocTextRange] = NodePath.forNode(newNode.id); } DocumentSelection? _calculateNewDocumentSelection(TextEditingDelta delta) { diff --git a/super_editor/lib/src/default_editor/document_ime/document_serialization.dart b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart index b7824a7baa..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 @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_selection.dart'; @@ -37,15 +38,37 @@ class DocumentImeSerializer { final Document _doc; DocumentSelection selection; DocumentRange? composingRegion; - final imeRangesToDocTextNodes = {}; - final docTextNodesToImeRanges = {}; + final imeRangesToDocTextNodes = {}; + final docTextNodesToImeRanges = {}; final selectedNodes = []; late String imeText; final PrependedCharacterPolicy _prependedCharacterPolicy; String _prependedPlaceholder = ''; + // TextNode(1) - Hello world + // TextNode(2) - Paragraph 2 + // ImageNode(3) + // TextNode(4) - YOLO + // CompositeNode(5) + // TextNode(6) - Inner paragraph + // ListItemNode(7) - Item 1 + // ListItemNode(8) - Item 2 + // ListItemNode(9) - Item 3 + // TextNode(10) - Final paragraph + + // CompositeNode(5) + // TextNode(6) - Inner para|graph + // + // CompositeNodePosition + // - node ID: "5" + // - child node ID: "6" + // - child node position: TextNodePosition(offset: 10) + // + // .Inner Paragraph + void _serialize() { editorImeLog.fine("Creating an IME model from document, selection, and composing region"); + print("Serializing document to send to the IME"); final buffer = StringBuffer(); int characterCount = 0; @@ -65,6 +88,8 @@ class DocumentImeSerializer { _prependedPlaceholder = ''; } + print("Selection: $selection"); + print(""); selectedNodes.clear(); selectedNodes.addAll(_doc.getNodesInContentOrder(selection)); for (int i = 0; i < selectedNodes.length; i += 1) { @@ -78,14 +103,23 @@ class DocumentImeSerializer { characterCount += 1; } - final node = selectedNodes[i]; + var node = selectedNodes[i]; + final nodePath = NodePath.forNode(node.id); + print("Serializing node for IME: $node"); + if (node is CompositeDocumentNode) { + final serializedCharacterCount = _serializeCompositeNode(NodePath.forNode(node.id), node, buffer); + characterCount += serializedCharacterCount; + + continue; + } + if (node is! TextNode) { buffer.write('~'); characterCount += 1; final imeRange = TextRange(start: characterCount - 1, end: characterCount); - imeRangesToDocTextNodes[imeRange] = node.id; - docTextNodesToImeRanges[node.id] = imeRange; + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; continue; } @@ -94,8 +128,8 @@ class DocumentImeSerializer { // so that we can easily convert between the two, when requested. final imeRange = TextRange(start: characterCount, end: characterCount + node.text.length); editorImeLog.finer("IME range $imeRange -> text node content '${node.text.toPlainText()}'"); - imeRangesToDocTextNodes[imeRange] = node.id; - docTextNodesToImeRanges[node.id] = imeRange; + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; // Concatenate this node's text with the previous nodes. buffer.write(node.text.toPlainText()); @@ -106,6 +140,49 @@ class DocumentImeSerializer { editorImeLog.fine("IME serialization:\n'$imeText'"); } + int _serializeCompositeNode(NodePath nodePath, CompositeDocumentNode node, StringBuffer buffer) { + int characterCount = 0; + for (final innerNode in node.nodes) { + final innerNodePath = nodePath.addSubPath(innerNode.id); + if (innerNode is CompositeDocumentNode) { + characterCount += _serializeCompositeNode(innerNodePath, innerNode, buffer); + continue; + } + + characterCount += _serializeNonCompositeNode(innerNodePath, node, buffer, characterCount); + + if (innerNode != node.nodes.last) { + buffer.write('\n'); + characterCount += 1; + } + } + + return characterCount; + } + + int _serializeNonCompositeNode(NodePath nodePath, DocumentNode node, StringBuffer buffer, int characterCount) { + if (node is! TextNode) { + buffer.write('~'); + + final imeRange = TextRange(start: characterCount - 1, end: characterCount); + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; + + return 1; + } + + // Cache mappings between the IME text range and the document position + // so that we can easily convert between the two, when requested. + final imeRange = TextRange(start: characterCount, end: characterCount + node.text.length); + editorImeLog.finer("IME range $imeRange -> text node content '${node.text.text}'"); + imeRangesToDocTextNodes[imeRange] = nodePath; + docTextNodesToImeRanges[nodePath] = imeRange; + + // Concatenate this node's text with the previous nodes. + buffer.write(node.text.text); + return node.text.length; + } + bool _shouldPrependPlaceholder() { if (_prependedCharacterPolicy == PrependedCharacterPolicy.include) { // The client explicitly requested prepended characters. This is @@ -265,28 +342,54 @@ class DocumentImeSerializer { DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { for (final range in imeRangesToDocTextNodes.keys) { if (range.start <= imePosition.offset && imePosition.offset <= range.end) { - final node = _doc.getNodeById(imeRangesToDocTextNodes[range]!)!; + final nodePath = imeRangesToDocTextNodes[range]!; + final node = _doc.getNodeById(nodePath.nodeIds.last)!; + late NodePosition contentNodePosition; if (node is TextNode) { - return DocumentPosition( - nodeId: imeRangesToDocTextNodes[range]!, - nodePosition: TextNodePosition(offset: imePosition.offset - range.start), - ); + contentNodePosition = TextNodePosition(offset: imePosition.offset - range.start); + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: TextNodePosition(offset: imePosition.offset - range.start), + // ); } else { if (imePosition.offset <= range.start) { // Return a position at the start of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.beginningPosition, - ); + contentNodePosition = node.beginningPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.beginningPosition, + // ); } else { // Return a position at the end of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.endPosition, - ); + contentNodePosition = node.endPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.endPosition, + // ); } } + + if (nodePath.nodeIds.length == 1) { + // This is a single node - not a composite node. Return it as-is. + return DocumentPosition( + documentPath: nodePath, + 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( + documentPath: NodePath([nodePath.nodeIds.first]), + nodePosition: compositeNodePosition, + ); } } @@ -303,13 +406,17 @@ class DocumentImeSerializer { editorImeLog.shout("IME Ranges to text nodes:"); for (final entry in imeRangesToDocTextNodes.entries) { editorImeLog.shout(" - IME range: ${entry.key} -> Text node: ${entry.value}"); - editorImeLog.shout(" ^ node content: '${(_doc.getNodeById(entry.value) as TextNode).text.toPlainText()}'"); + editorImeLog.shout(" ^ node content: '${_getTextNodeAtNodePath(entry.value).text.toPlainText()}'"); } editorImeLog.shout("-----------------------------------------------------------"); throw Exception( "Couldn't map an IME position to a document position. \nTextEditingValue: '$imeText'\nIME position: $imePosition"); } + TextNode _getTextNodeAtNodePath(NodePath path) { + return _doc.getNodeById(path.nodeIds.last) as TextNode; + } + TextSelection documentToImeSelection(DocumentSelection docSelection) { editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); final selectionAffinity = _doc.getAffinityForSelection(docSelection); @@ -346,9 +453,15 @@ class DocumentImeSerializer { TextPosition _documentToImePosition(DocumentPosition docPosition) { editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); - final imeRange = docTextNodesToImeRanges[docPosition.nodeId]; + // FIXME: don't assume top-level node + final nodePath = NodePath.forNode(docPosition.nodeId); + final imeRange = docTextNodesToImeRanges[nodePath]; if (imeRange == null) { - throw Exception("No such document position in the IME content: $docPosition"); + print("Available node paths in mapping:"); + for (final entry in docTextNodesToImeRanges.entries) { + print(" - ${entry.key}"); + } + throw Exception("No such node path in the IME content: $nodePath"); } final nodePosition = docPosition.nodePosition; @@ -371,6 +484,16 @@ class DocumentImeSerializer { return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); } + if (nodePosition is CompositeNodePosition) { + 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); + } + throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); } diff --git a/super_editor/lib/src/default_editor/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/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 37ad0e68e1..3db9aaf4fc 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -68,7 +68,11 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { const HorizontalRuleComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! HorizontalRuleNode) { return null; } diff --git a/super_editor/lib/src/default_editor/image.dart b/super_editor/lib/src/default_editor/image.dart index c35aeed030..da2a4b0f04 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -108,7 +108,11 @@ class ImageComponentBuilder implements ComponentBuilder { const ImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ImageNode) { return null; } diff --git a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index d633a4a61a..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, ); } @@ -970,13 +970,18 @@ class _Component extends StatelessWidget { @override Widget build(BuildContext context) { + print("Layout build()"); final componentContext = SingleColumnDocumentComponentContext( context: context, componentKey: componentKey, + componentBuilders: List.unmodifiable(componentBuilders), ); + print("Building component for view model: $componentViewModel"); for (final componentBuilder in componentBuilders) { + print(" - Trying to create component with build: $componentBuilder"); var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { + print(" - This builder gave us a component"); // TODO: we might need a SizeChangedNotifier here for the case where two components // change size exactly inversely component = ConstrainedBox( diff --git a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart index 90827514fe..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 @@ -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 @@ -99,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) { @@ -167,7 +177,7 @@ class SingleColumnLayoutPresenter { for (final node in _document) { SingleColumnLayoutComponentViewModel? viewModel; for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, node); + viewModel = builder.createViewModel(_document, node, _componentBuilders); if (viewModel != null) { break; } @@ -365,7 +375,11 @@ typedef ViewModelChangeCallback = void Function({ abstract class ComponentBuilder { /// Produces a [SingleColumnLayoutComponentViewModel] with default styles for the given /// [node], or returns `null` if this builder doesn't apply to the given node. - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node); + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ); /// Creates a visual component that renders the given [viewModel], /// or returns `null` if this builder doesn't apply to the given [viewModel]. diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index f663143c21..36aab31892 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -151,7 +151,11 @@ class ListItemComponentBuilder implements ComponentBuilder { const ListItemComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ListItemNode) { return null; } @@ -1047,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 dfadf61136..971e915c18 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -105,7 +105,11 @@ class ParagraphComponentBuilder implements ComponentBuilder { const ParagraphComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } @@ -746,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), ), ); @@ -803,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); @@ -900,7 +907,7 @@ class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeAbove.id, + documentPath: document.getPathByNodeId(nodeAbove.id)!, nodePosition: TextNodePosition(offset: aboveParagraphLength), ), ), @@ -935,7 +942,7 @@ class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: nodeBefore.id, + documentPath: document.getPathByNodeId(nodeBefore.id)!, nodePosition: nodeBefore.endPosition, ), ), @@ -1421,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/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index d7457bc714..a7499b1431 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -14,6 +14,7 @@ import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/composite_component.dart'; import 'package:super_editor/src/default_editor/debug_visualization.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; @@ -1349,6 +1350,7 @@ const defaultComponentBuilders = [ ListItemComponentBuilder(), ImageComponentBuilder(), HorizontalRuleComponentBuilder(), + CompositeComponentBuilder(), ]; /// Default list of document overlays that are displayed on top of the document diff --git a/super_editor/lib/src/default_editor/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 daf3116b1b..401f85e5e3 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -158,7 +158,11 @@ class TaskComponentBuilder implements ComponentBuilder { final Editor _editor; @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } @@ -673,7 +677,7 @@ class InsertNewlineInTaskAtCaretCommand extends BaseInsertNewlineAtCaretCommand ChangeSelectionCommand( DocumentSelection.collapsed( position: DocumentPosition( - nodeId: newNodeId, + documentPath: context.document.getPathByNodeId(newNodeId)!, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -910,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/default_editor/unknown_component.dart b/super_editor/lib/src/default_editor/unknown_component.dart index 6a90defe3f..8644c89ac2 100644 --- a/super_editor/lib/src/default_editor/unknown_component.dart +++ b/super_editor/lib/src/default_editor/unknown_component.dart @@ -8,7 +8,11 @@ class UnknownComponentBuilder implements ComponentBuilder { const UnknownComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return _UnkownViewModel( nodeId: node.id, padding: EdgeInsets.zero, diff --git a/super_editor/lib/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/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/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 diff --git a/super_editor/test/super_editor/super_editor_embedded_documents_test.dart b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart new file mode 100644 index 0000000000..10a9fd191b --- /dev/null +++ b/super_editor/test/super_editor/super_editor_embedded_documents_test.dart @@ -0,0 +1,37 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; + +import 'supereditor_test_tools.dart'; + +void main() { + group("SuperEditor embedded documents >", () { + testWidgetsOnAllPlatforms("displays embedded documents", (tester) async { + tester.view.physicalSize = const Size(600, 600); + addTearDown(() { + tester.view.resetPhysicalSize(); + }); + + await tester + .createDocument() + .withCustomContent(MutableDocument(nodes: [ + ParagraphNode(id: "1.1", text: AttributedText("Paragraph before the first level of embedding.")), + CompositeDocumentNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + CompositeDocumentNode("3", [ + ParagraphNode(id: "3.1", text: AttributedText("This paragraph is in the 3rd level of document.")), + ]), + ParagraphNode(id: "2.3", text: AttributedText("Paragraph after the second level of embedding.")), + ]), + ParagraphNode(id: "1.3", text: AttributedText("Paragraph after the first level of embedding.")), + ])) + .pump(); + + await expectLater(find.byType(MaterialApp), matchesGoldenFile("deletme.png")); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_component_selection_test.dart b/super_editor/test/super_editor/supereditor_component_selection_test.dart index 31cc7db618..fc98e2e9de 100644 --- a/super_editor/test/super_editor/supereditor_component_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_component_selection_test.dart @@ -597,7 +597,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; @@ -756,7 +760,11 @@ class _ButtonComponentBuilder implements ComponentBuilder { const _ButtonComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! _ButtonNode) { return null; } diff --git a/super_editor/test/super_editor/supereditor_components_test.dart b/super_editor/test/super_editor/supereditor_components_test.dart index 4643215e19..62c0331190 100644 --- a/super_editor/test/super_editor/supereditor_components_test.dart +++ b/super_editor/test/super_editor/supereditor_components_test.dart @@ -100,7 +100,11 @@ class HintTextComponentBuilder implements ComponentBuilder { const HintTextComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This component builder can work with the standard paragraph view model. // We'll defer to the standard paragraph component builder to create it. return null; @@ -179,7 +183,11 @@ class _FakeImageComponentBuilder implements ComponentBuilder { const _FakeImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return null; } diff --git a/super_editor/test/super_editor/supereditor_input_ime_test.dart b/super_editor/test/super_editor/supereditor_input_ime_test.dart index 336a39b58c..f50ccf80e1 100644 --- a/super_editor/test/super_editor/supereditor_input_ime_test.dart +++ b/super_editor/test/super_editor/supereditor_input_ime_test.dart @@ -1107,7 +1107,7 @@ Paragraph two ); }); - group('text serialization and selected content', () { + group('text serialization and selected content >', () { test('within a single node is reported as a TextEditingValue', () { const text = "This is a paragraph of text."; @@ -1184,6 +1184,62 @@ Paragraph two ); }); + test('text within a composite node reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + CompositeDocumentNode("1", [ + ParagraphNode(id: "2", text: AttributedText(text)), + ]), + ]), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: CompositeNodePosition( + compositeNodeId: "1", + childNodeId: "2", + childNodePosition: TextNodePosition(offset: 10), + ), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph of text.", + ); + }); + + test('text within composite nodes and non-text in between reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + CompositeDocumentNode("1", [ + ParagraphNode(id: "2", text: AttributedText(text)), + ]), + HorizontalRuleNode(id: "3"), + CompositeDocumentNode("4", [ + ParagraphNode(id: "5", text: AttributedText(text)), + ]) + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + extent: DocumentPosition( + nodeId: "3", + nodePosition: TextNodePosition(offset: 19), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph of text.\n~\nThis is a paragraph| of text.", + ); + }); + test('text with non-text end-caps reported as a TextEditingValue', () { const text = "This is the first paragraph of text."; diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 8910d8b6a2..57042b32ed 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -1231,7 +1231,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 9399e5d83a..f8f7863fd0 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -1015,7 +1015,11 @@ class FakeImageComponentBuilder implements ComponentBuilder { final Color? fillColor; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return null; } @@ -1046,7 +1050,11 @@ class FakeImageComponentBuilder implements ComponentBuilder { /// [TaskNode] in a document. class ExpandingTaskComponentBuilder extends ComponentBuilder { @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } diff --git a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart index 122befc497..e5eddb17dc 100644 --- a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart +++ b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart @@ -2848,7 +2848,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/test/super_reader/super_reader_selection_test.dart b/super_editor/test/super_reader/super_reader_selection_test.dart index d650b555ea..e4dbdad274 100644 --- a/super_editor/test/super_reader/super_reader_selection_test.dart +++ b/super_editor/test/super_reader/super_reader_selection_test.dart @@ -497,7 +497,11 @@ class _UnselectableHrComponentBuilder implements ComponentBuilder { const _UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/test_goldens/editor/components/list_items_test.dart b/super_editor/test_goldens/editor/components/list_items_test.dart index 37915dd415..4cf7c89732 100644 --- a/super_editor/test_goldens/editor/components/list_items_test.dart +++ b/super_editor/test_goldens/editor/components/list_items_test.dart @@ -397,14 +397,18 @@ class _ListItemWithCustomStyleBuilder implements ComponentBuilder { final OrderedListNumeralStyle? numeralStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ListItemNode) { return null; } // Use the default component builder to create the view model, because we only want // to customize the style. - final viewModel = const ListItemComponentBuilder().createViewModel(document, node); + final viewModel = const ListItemComponentBuilder().createViewModel(document, node, componentBuilders); if (viewModel is UnorderedListItemComponentViewModel && dotStyle != null) { viewModel.dotStyle = dotStyle!; @@ -417,7 +421,9 @@ class _ListItemWithCustomStyleBuilder implements ComponentBuilder { @override Widget? createComponent( - SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { // We can use the default component for list items. return null; }