diff --git a/super_clones/quill/lib/editor/code_component.dart b/super_clones/quill/lib/editor/code_component.dart index 1a878fb141..e44c93edf6 100644 --- a/super_clones/quill/lib/editor/code_component.dart +++ b/super_clones/quill/lib/editor/code_component.dart @@ -5,7 +5,11 @@ class FeatherCodeComponentBuilder implements ComponentBuilder { const FeatherCodeComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/example/lib/demos/components/demo_text_with_hint.dart b/super_editor/example/lib/demos/components/demo_text_with_hint.dart index 527590a19d..8878769436 100644 --- a/super_editor/example/lib/demos/components/demo_text_with_hint.dart +++ b/super_editor/example/lib/demos/components/demo_text_with_hint.dart @@ -146,7 +146,11 @@ class HeaderWithHintComponentBuilder implements ComponentBuilder { const HeaderWithHintComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This component builder can work with the standard paragraph view model. // We'll defer to the standard paragraph component builder to create it. return null; diff --git a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart index 64983b8095..55fcecef29 100644 --- a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart +++ b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart @@ -71,7 +71,11 @@ class UnselectableHrComponentBuilder implements ComponentBuilder { const UnselectableHrComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard horizontal rule view model, so // we'll defer to the standard horizontal rule builder. return null; diff --git a/super_editor/example/lib/demos/demo_animated_task_height.dart b/super_editor/example/lib/demos/demo_animated_task_height.dart index 2ee35da6b1..a8e46318a7 100644 --- a/super_editor/example/lib/demos/demo_animated_task_height.dart +++ b/super_editor/example/lib/demos/demo_animated_task_height.dart @@ -72,7 +72,11 @@ class AnimatedTaskComponentBuilder implements ComponentBuilder { const AnimatedTaskComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart b/super_editor/example/lib/demos/in_the_lab/feature_composite_nodes.dart new file mode 100644 index 0000000000..4bf159e4d5 --- /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.")), + GroupNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + GroupNode("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! GroupNode) { + 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 GroupNode 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/feature_tables.dart b/super_editor/example/lib/demos/in_the_lab/feature_tables.dart new file mode 100644 index 0000000000..5059f476fb --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_tables.dart @@ -0,0 +1,79 @@ +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 TablesDemo extends StatefulWidget { + const TablesDemo({super.key}); + + @override + State createState() => _TablesDemoState(); +} + +class _TablesDemoState extends State { + late final Editor _editor; + + @override + void initState() { + print("Tables demo initState"); + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createInitialDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + print("Building Table Demo"); + return InTheLabScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: darkModeStyles, + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + componentBuilders: [ + const TableComponentBuilder(), + ...defaultComponentBuilders, + ], + ), + ); + } +} + +MutableDocument _createInitialDocument() { + print("Creating initial document"); + return MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Tables"), + metadata: { + NodeMetadata.blockType: header1Attribution, + }, + ), + TableNode.sparse( + "2", + { + (row: 0, col: 0): TableCellNode( + "3", + [], + ), + (row: 0, col: 1): TableCellNode( + "4", + [], + ), + (row: 0, col: 2): TableCellNode( + "5", + [], + ), + }, + ), + ], + ); +} 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 4717a901e4..87f79e4b09 100644 --- a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart +++ b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart @@ -181,8 +181,13 @@ class SpellingErrorParagraphComponentBuilder implements ComponentBuilder { final UnderlineStyle underlineStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - final viewModel = ParagraphComponentBuilder().createViewModel(document, node) as ParagraphComponentViewModel?; + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + final viewModel = + ParagraphComponentBuilder().createViewModel(document, node, componentBuilders) as ParagraphComponentViewModel?; if (viewModel == null) { return null; } diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 8b0a94dd58..2c66d4a0b0 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -15,9 +15,11 @@ 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'; +import 'package:example/demos/in_the_lab/feature_tables.dart'; import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart'; import 'package:example/demos/in_the_lab/spelling_error_decorations.dart'; import 'package:example/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart'; @@ -333,6 +335,20 @@ final _menu = <_MenuGroup>[ return const NativeIosContextMenuFeatureDemo(); }, ), + _MenuItem( + icon: Icons.account_tree, + title: 'Tables', + pageBuilder: (context) { + return const TablesDemo(); + }, + ), + _MenuItem( + icon: Icons.account_tree, + title: 'Embedded Components', + pageBuilder: (context) { + return const CompositeNodesDemo(); + }, + ), ], ), _MenuGroup( diff --git a/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5b055a3a37..12076c83de 100644 --- a/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/super_editor/example_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart index 5d7772c971..7b54cbecbe 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..83968a0794 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -38,6 +38,13 @@ 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 [DocumentNode] at the given [index], or [null] /// if no such node exists. DocumentNode? getNodeAt(int index); @@ -283,6 +290,25 @@ class DocumentPosition { /// For example: a paragraph node might use a [TextNodePosition]. final NodePosition nodePosition; + /// The most specific node (i.e., deepest descendant node) that this [DocumentPosition] + /// points to. + /// + /// For a [Document] that contains a list of nodes, [targetNodeId] is the same + /// as [nodeId]. + /// + /// For a [Document] that contains a tree of nodes, a [DocumentPosition] might + /// point down a branch. For example, a [DocumentPosition] might point to a table, + /// and to a cell in that table, and to a character of text within that cell. In + /// that case, the [targetNodeId] would be the ID of the `TextNode`, within the + /// cell, within the table. + String get targetNodeId { + if (nodePosition is! CompositeNodePosition) { + return nodeId; + } + + return (nodePosition as CompositeNodePosition).targetNodeId; + } + /// Whether this position within the document is equivalent to the given /// [other] [DocumentPosition]. /// @@ -481,6 +507,402 @@ 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 [GroupNode]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); +} + +// /// The path to a [DocumentNode] within a [Document]. +// /// +// /// In the average case, the [NodePath] is effectively the same as a node's +// /// ID. However, some nodes are [CompositeDocumentNode]s, which have a hierarchy. +// /// For a composite node, the node path includes every node ID in the composite +// /// hierarchy. +// class NodePath { +// factory NodePath.forDocumentPosition(DocumentPosition position) { +// var nodePosition = position.nodePosition; +// if (nodePosition is CompositeNodePosition) { +// // This node position is a hierarchy of nodes. Encode all nodes +// // along that path into the node path. +// final nodeIds = [position.nodeId]; +// +// while (nodePosition is CompositeNodePosition) { +// nodeIds.add(nodePosition.childNodeId); +// nodePosition = nodePosition.childNodePosition; +// } +// +// return NodePath(nodeIds); +// } +// +// // This position refers to a singular node. Build a node path that only +// // contains this node's ID. +// return NodePath([position.nodeId]); +// } +// +// factory NodePath.forNode(String nodeId) { +// return NodePath([nodeId]); +// } +// +// const NodePath(this.nodeIds); +// +// final List nodeIds; +// +// NodePath addSubPath(String nodeId) => NodePath([...nodeIds, nodeId]); +// +// @override +// String toString() => "[NodePath] - ${nodeIds.join(" > ")}"; +// +// @override +// bool operator ==(Object other) => +// identical(this, other) || +// other is NodePath && +// runtimeType == other.runtimeType && +// const DeepCollectionEquality().equals(nodeIds, other.nodeIds); +// +// @override +// int get hashCode => const ListEquality().hash(nodeIds); +// } + +/// A [DocumentNode] that contains other [DocumentNode]s in a hierarchy. +/// +/// [GroupNode]s can contain more [GroupNode]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 GroupNode extends DocumentNode { + GroupNode(this.id, this._nodes); // : assert(_nodes.isNotEmpty, "GroupNode'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 GroupNode(id, List.from(_nodes)); + } + + @override + String toString() => "[CompositeNode] - $_nodes"; +} + +/// A selection within a single [GroupNode]. +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 [GroupNode], 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; + + /// The ID of the deepest node that this position points to. + String get targetNodeId { + if (childNodePosition is! CompositeNodePosition) { + return childNodeId; + } + + return (childNodePosition as CompositeNodePosition).targetNodeId; + } + + @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 + String toString() => "[CompositeNodePosition] -> $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/editor.dart b/super_editor/lib/src/core/editor.dart index 2a873f5f2f..97e4200d1a 100644 --- a/super_editor/lib/src/core/editor.dart +++ b/super_editor/lib/src/core/editor.dart @@ -1121,6 +1121,49 @@ class MutableDocument with Iterable implements Document, Editable return _nodesById[nodeId]; } + @override + DocumentNode? getNodeAtPath(NodePath path) { + return _nodesById[path.targetNodeId]; + } + + @override + NodePath? getPathByNodeId(String nodeId) { + print("getPathByNodeId(): $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. + + print("Root nodes: ${_nodes.map((node) => node.id).join(", ")}"); + final queue = <(NodePath, List)>[ + (const NodePath([]), [..._nodes]) + ]; + while (queue.isNotEmpty) { + final (parentNodePath, children) = queue.removeAt(0); + print("Looking within the path: $parentNodePath, children: ${children.map((node) => node.id).join(", ")}"); + + for (final child in children) { + if (child.id == nodeId) { + print("Found a node we're searching for: $nodeId"); + print(" - parent node path before finding this one: $parentNodePath"); + print(" - returning new path: ${parentNodePath.addSubPath(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 GroupNode) { + print(" - this is a composite node. Adding another level to the search queue."); + print(" - adding subpath: ${parentNodePath.addSubPath(nodeId)}"); + print(" - subpath has children: ${child.nodes.map((node) => node.id).join(", ")}"); + // This child might also have children. Add them to the visit queue. + queue.add((parentNodePath.addSubPath(child.id), [...child.nodes])); + } + } + } + + // We never found the node. + return null; + } + @override DocumentNode? getNodeAt(int index) { if (index < 0 || index >= _nodes.length) { diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 7a176f2795..e3fdd1d21d 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -25,7 +25,11 @@ class BlockquoteComponentBuilder implements ComponentBuilder { const BlockquoteComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/lib/src/default_editor/common_editor_operations.dart b/super_editor/lib/src/default_editor/common_editor_operations.dart index 102be8c8c9..c99970d2c6 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -439,6 +439,7 @@ class CommonEditorOperations { throw Exception( 'Could not find next component to move the selection horizontally. Next node ID: ${nextNode.id}'); } + print("Next component: $nextComponent, beginning position: ${nextComponent.getBeginningPosition()}"); newExtentNodePosition = nextComponent.getBeginningPosition(); } diff --git a/super_editor/lib/src/default_editor/composite_component.dart b/super_editor/lib/src/default_editor/composite_component.dart new file mode 100644 index 0000000000..d9d51e3bdb --- /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! GroupNode) { + 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 GroupNode 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 GroupNode node; + final List childComponents; + + @override + State createState() => _CompositeComponentState(); +} + +class _CompositeComponentState extends State with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return widget.node.beginningPosition; + } + + @override + NodePosition getBeginningPositionNearX(double x) { + // TODO: implement getBeginningPositionNearX + throw UnimplementedError(); + } + + @override + NodePosition getEndPosition() { + return widget.node.endPosition; + } + + @override + NodePosition getEndPositionNearX(double x) { + // TODO: implement getEndPositionNearX + throw UnimplementedError(); + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + return widget.node.computeSelection(base: nodePosition, extent: nodePosition); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + // TODO: implement getDesiredCursorAtOffset + throw UnimplementedError(); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + // TODO: implement getEdgeForPosition + throw UnimplementedError(); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + // TODO: implement getOffsetForPosition + throw UnimplementedError(); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + // TODO: implement getPositionAtOffset + throw UnimplementedError(); + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + // TODO: implement getRectForPosition + throw UnimplementedError(); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + // TODO: implement getRectForSelection + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + // TODO: implement getSelectionBetween + throw UnimplementedError(); + } + + @override + NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { + // TODO: implement getSelectionInRange + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionOfEverything() { + // TODO: implement getSelectionOfEverything + throw UnimplementedError(); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + // TODO: implement movePositionDown + throw UnimplementedError(); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionLeft + throw UnimplementedError(); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + // TODO: implement movePositionRight + throw UnimplementedError(); + } + + @override + NodePosition? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + throw UnimplementedError(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey), + color: Colors.grey.withOpacity(0.1), + ), + padding: const EdgeInsets.all(24), + child: Column( + children: widget.childComponents, + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart index 0166c51caf..59dec03e65 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 @@ -506,13 +506,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; } @@ -522,8 +524,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..c6764ba8b4 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 GroupNode) { + 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,54 @@ class DocumentImeSerializer { editorImeLog.fine("IME serialization:\n'$imeText'"); } + int _serializeCompositeNode(NodePath nodePath, GroupNode node, StringBuffer buffer) { + print("Serializing a composite node: $nodePath"); + int characterCount = 0; + for (final innerNode in node.nodes) { + final innerNodePath = nodePath.addSubPath(innerNode.id); + if (innerNode is GroupNode) { + characterCount += _serializeCompositeNode(innerNodePath, innerNode, buffer); + continue; + } + + characterCount += _serializeNonCompositeNode(innerNodePath, innerNode, buffer, characterCount); + + if (innerNode != node.nodes.last) { + buffer.write('\n'); + characterCount += 1; + } + } + + return characterCount; + } + + int _serializeNonCompositeNode(NodePath nodePath, DocumentNode node, StringBuffer buffer, int characterCount) { + print("Serializing a non-composite node: $nodePath"); + print("Node: $node"); + if (node is! TextNode) { + print("This node isn't a text node. Serializing to ~"); + 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.toPlainText()}'"); + print("IME range for TextNode: $imeRange. Content: ${node.text.toPlainText()}"); + 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 +347,54 @@ class DocumentImeSerializer { DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { for (final range in imeRangesToDocTextNodes.keys) { if (range.start <= imePosition.offset && imePosition.offset <= range.end) { - final node = _doc.getNodeById(imeRangesToDocTextNodes[range]!)!; + final nodePath = imeRangesToDocTextNodes[range]!; + final node = _doc.getNodeById(nodePath.nodeIds.last)!; + late NodePosition contentNodePosition; if (node is TextNode) { - return DocumentPosition( - nodeId: imeRangesToDocTextNodes[range]!, - nodePosition: TextNodePosition(offset: imePosition.offset - range.start), - ); + contentNodePosition = TextNodePosition(offset: imePosition.offset - range.start); + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: TextNodePosition(offset: imePosition.offset - range.start), + // ); } else { if (imePosition.offset <= range.start) { // Return a position at the start of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.beginningPosition, - ); + contentNodePosition = node.beginningPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.beginningPosition, + // ); } else { // Return a position at the end of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.endPosition, - ); + contentNodePosition = node.endPosition; + // return DocumentPosition( + // nodeId: node.id, + // nodePosition: node.endPosition, + // ); } } + + if (nodePath.nodeIds.length == 1) { + // This is a single node - not a composite node. Return it as-is. + return DocumentPosition( + nodeId: node.id, + nodePosition: contentNodePosition, + ); + } + + NodePosition compositeNodePosition = contentNodePosition; + for (int i = nodePath.nodeIds.length - 2; i >= 0; i -= 1) { + compositeNodePosition = CompositeNodePosition( + compositeNodeId: nodePath.nodeIds[i], + childNodeId: nodePath.nodeIds[i + 1], + childNodePosition: compositeNodePosition, + ); + } + return DocumentPosition( + nodeId: nodePath.nodeIds.first, + nodePosition: compositeNodePosition, + ); } } @@ -303,13 +411,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 +458,16 @@ class DocumentImeSerializer { TextPosition _documentToImePosition(DocumentPosition docPosition) { editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); - final imeRange = docTextNodesToImeRanges[docPosition.nodeId]; + print("_documentToImePosition() - position: $docPosition"); + final nodePath = _doc.getPathByNodeId(docPosition.targetNodeId); + print("Looking up IME range for node path: $nodePath"); + 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}: ${entry.value}"); + } + throw Exception("No such node path in the IME content: $nodePath"); } final nodePosition = docPosition.nodePosition; @@ -371,6 +490,14 @@ class DocumentImeSerializer { return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); } + if (nodePosition is CompositeNodePosition) { + final innerDocumentPosition = + DocumentPosition(nodeId: nodePosition.childNodeId, nodePosition: nodePosition.childNodePosition); + + // Recursive call to create the IME text position for the content within the composite node. + return _documentToImePosition(innerDocumentPosition); + } + throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); } diff --git a/super_editor/lib/src/default_editor/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 37ad0e68e1..3db9aaf4fc 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -68,7 +68,11 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { const HorizontalRuleComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! HorizontalRuleNode) { return null; } diff --git a/super_editor/lib/src/default_editor/image.dart b/super_editor/lib/src/default_editor/image.dart index c35aeed030..da2a4b0f04 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -108,7 +108,11 @@ class ImageComponentBuilder implements ComponentBuilder { const ImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ImageNode) { return null; } diff --git a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index d633a4a61a..f6f3ea8d51 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart @@ -970,13 +970,18 @@ class _Component extends StatelessWidget { @override Widget build(BuildContext context) { + print("Layout build()"); final componentContext = SingleColumnDocumentComponentContext( context: context, componentKey: componentKey, + componentBuilders: List.unmodifiable(componentBuilders), ); + print("Building component for view model: $componentViewModel"); for (final componentBuilder in componentBuilders) { + print(" - Trying to create component with build: $componentBuilder"); var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { + print(" - This builder gave us a component"); // TODO: we might need a SizeChangedNotifier here for the case where two components // change size exactly inversely component = ConstrainedBox( diff --git a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart index 90827514fe..b74682215b 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart @@ -13,6 +13,7 @@ class SingleColumnDocumentComponentContext { const SingleColumnDocumentComponentContext({ required this.context, required this.componentKey, + required this.componentBuilders, }); /// The [BuildContext] for the parent of the [DocumentComponent] @@ -25,6 +26,10 @@ class SingleColumnDocumentComponentContext { /// The [componentKey] is used by the [DocumentLayout] to query for /// node-specific information, like node positions and selections. final GlobalKey componentKey; + + /// All registered [ComponentBuilder]s for the document layout, which can + /// be used to create components within components. + final List componentBuilders; } /// Produces [SingleColumnLayoutViewModel]s to be displayed by a @@ -167,7 +172,7 @@ class SingleColumnLayoutPresenter { for (final node in _document) { SingleColumnLayoutComponentViewModel? viewModel; for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, node); + viewModel = builder.createViewModel(_document, node, _componentBuilders); if (viewModel != null) { break; } @@ -363,9 +368,15 @@ typedef ViewModelChangeCallback = void Function({ /// Creates view models and components to display various [DocumentNode]s /// in a [Document]. abstract class ComponentBuilder { + const 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 7151b4b36a..4c7f24b277 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -152,7 +152,11 @@ class ListItemComponentBuilder implements ComponentBuilder { const ListItemComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! ListItemNode) { return null; } diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index 9b22f39a0d..d66c3b44c9 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; } @@ -314,6 +318,7 @@ class HintComponentBuilder extends ParagraphComponentBuilder { SingleColumnLayoutComponentViewModel? createViewModel( Document document, DocumentNode node, + List componentBuilders, ) { if (node is! ParagraphNode) { return null; @@ -335,7 +340,7 @@ class HintComponentBuilder extends ParagraphComponentBuilder { } return HintComponentViewModel.fromParagraphViewModel( - super.createViewModel(document, node)! as ParagraphComponentViewModel, + super.createViewModel(document, node, componentBuilders)! as ParagraphComponentViewModel, hintText: hint, ); } diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 1d5818e3d5..32599b34d3 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'; @@ -1351,6 +1352,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/table.dart b/super_editor/lib/src/default_editor/table.dart new file mode 100644 index 0000000000..b20473eb63 --- /dev/null +++ b/super_editor/lib/src/default_editor/table.dart @@ -0,0 +1,301 @@ +import 'package:flutter/widgets.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/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; + +class TableNode extends GroupNode { + TableNode.sparse( + String id, + this.cells, + ) : super(id, cells.values.toList()) { + print("Creating TableNode"); + // Compute the number of rows and columns. + final rowIndices = {}; + final columnIndices = {}; + for (final cell in cells.keys) { + rowIndices.add(cell.row); + columnIndices.add(cell.col); + } + rowCount = rowIndices.length; + columnCount = columnIndices.length; + print("Done with TableNode constructor"); + } + + final Map cells; + + late final int rowCount; + late final int columnCount; + + TableCellNode? getCellAt(TableCellPosition position) => cells[position]; + + @override + NodePosition get beginningPosition => TableNodePosition( + (row: 0, col: 0), + getCellAt((row: 0, col: 0))!.endPosition, + ); + + @override + NodePosition get endPosition => CompositeNodePosition( + compositeNodeId: id, + childNodeId: _nodes.last.id, + childNodePosition: _nodes.last.endPosition, + ); +} + +/// A selection within a single [TableNode]. +class TableNodeSelection implements NodeSelection { + const TableNodeSelection.collapsed( + TableNodePosition position, + ) : base = position, + extent = position; + + const TableNodeSelection({ + required this.base, + required this.extent, + }); + + final TableNodePosition base; + final TableNodePosition extent; +} + +/// A singular position within a [TableNode]. +class TableNodePosition implements NodePosition { + const TableNodePosition(this.cell, this.cellPath); + + final TableCellPosition cell; + final NodePath cellPath; + + @override + bool isEquivalentTo(NodePosition other) { + if (other is! TableNodePosition) { + return false; + } + + return cell == other.cell && cellPath == other.cellPath; + } +} + +typedef TableCellPosition = ({int row, int col}); + +class TableCellNode extends GroupNode { + TableCellNode(super.id, super.nodes); +} + +class TableComponentBuilder implements ComponentBuilder { + const TableComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { + if (node is! TableNode) { + return null; + } + + print("Creating TableViewModel"); + return TableViewModel( + node: node, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! TableViewModel) { + return null; + } + + print("Creating TableComponent"); + return TableComponent( + key: componentContext.componentKey, + node: componentViewModel.node, + ); + } +} + +class TableViewModel extends SingleColumnLayoutComponentViewModel { + TableViewModel({ + required this.node, + EdgeInsetsGeometry padding = EdgeInsets.zero, + double? maxWidth, + }) : super(nodeId: node.id, padding: padding, maxWidth: maxWidth); + + final TableNode node; + + @override + TableViewModel copy() { + return TableViewModel( + node: node, + padding: padding, + maxWidth: maxWidth, + ); + } +} + +class TableComponent extends StatefulWidget { + const TableComponent({ + super.key, + required this.node, + }); + + final TableNode node; + + @override + State createState() => _TableComponentState(); +} + +class _TableComponentState extends State with DocumentComponent { + final _cellKeys = >[]; + + @override + void initState() { + super.initState(); + + for (int row = 0; row < widget.node.rowCount; row += 1) { + for (int col = 0; col < widget.node.columnCount; col += 1) { + if (row >= _cellKeys.length) { + _cellKeys.add([]); + } + if (col >= _cellKeys[row].length) { + _cellKeys[row].add(GlobalKey(debugLabel: "Cell (row: $row, col: $col)")); + } + } + } + } + + @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) { + if (nodePosition is! TableNodePosition) { + throw Exception('The given nodePosition ($nodePosition) is not compatible with TableComponent'); + } + + return TableNodeSelection.collapsed(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? movePositionUp(NodePosition currentPosition) { + // TODO: implement movePositionUp + 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 + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int row = 0; row < widget.node.rowCount; row += 1) // + Row( + children: [ + for (int col = 0; col < widget.node.columnCount; col += 1) // + Expanded( + key: _cellKeys[row][col], + child: Container( + margin: const EdgeInsets.all(10), + color: const Color(0xFFFF0000), + ), + ), + ], + ), + ], + ); + } +} diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index f89a7e3761..e664d848e6 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -159,7 +159,11 @@ class TaskComponentBuilder implements ComponentBuilder { final Editor _editor; @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } diff --git a/super_editor/lib/src/default_editor/unknown_component.dart b/super_editor/lib/src/default_editor/unknown_component.dart index 6a90defe3f..8644c89ac2 100644 --- a/super_editor/lib/src/default_editor/unknown_component.dart +++ b/super_editor/lib/src/default_editor/unknown_component.dart @@ -8,7 +8,11 @@ class UnknownComponentBuilder implements ComponentBuilder { const UnknownComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { return _UnkownViewModel( nodeId: node.id, padding: EdgeInsets.zero, diff --git a/super_editor/lib/src/super_reader/tasks.dart b/super_editor/lib/src/super_reader/tasks.dart index 6137e6c4c6..b99183cdbe 100644 --- a/super_editor/lib/src/super_reader/tasks.dart +++ b/super_editor/lib/src/super_reader/tasks.dart @@ -13,7 +13,11 @@ class ReadOnlyTaskComponentBuilder implements ComponentBuilder { const ReadOnlyTaskComponentBuilder(); @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel( + Document document, + DocumentNode node, + List componentBuilders, + ) { if (node is! TaskNode) { return null; } diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index b34183c9b7..e4e83d9179 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -24,6 +24,7 @@ export 'src/default_editor/blockquote.dart'; export 'src/default_editor/box_component.dart'; export 'src/default_editor/common_editor_operations.dart'; export 'src/default_editor/composer/composer_reactions.dart'; +export 'src/default_editor/composite_component.dart'; export 'src/default_editor/debug_visualization.dart'; export 'src/default_editor/default_document_editor.dart'; export 'src/default_editor/default_document_editor_reactions.dart'; @@ -48,6 +49,7 @@ export 'src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; export 'src/default_editor/selection_binary.dart'; export 'src/default_editor/selection_upstream_downstream.dart'; export 'src/default_editor/super_editor.dart'; +export 'src/default_editor/table.dart'; export 'src/default_editor/tasks.dart'; export 'src/default_editor/text.dart'; export 'src/default_editor/text_tools.dart'; diff --git a/super_editor/test/super_editor/deletme.png b/super_editor/test/super_editor/deletme.png new file mode 100644 index 0000000000..6c50ffce87 Binary files /dev/null and b/super_editor/test/super_editor/deletme.png differ diff --git a/super_editor/test/super_editor/infrastructure/document_test.dart b/super_editor/test/super_editor/infrastructure/document_test.dart index ca5aa18576..c1d0fbfcf2 100644 --- a/super_editor/test/super_editor/infrastructure/document_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_test.dart @@ -3,6 +3,18 @@ import 'package:super_editor/super_editor.dart'; void main() { group("Document", () { + group("node paths >", () { + test("equality", () { + expect(NodePath.forNode("1"), equals(NodePath.forNode("1"))); + expect(NodePath.forNode("1"), isNot(equals(NodePath.forNode("2")))); + + final map = { + NodePath.forNode("1"): "Hello", + }; + expect(map[NodePath.forNode("1")], "Hello"); + }); + }); + group("nodes", () { group("equality", () { test("equivalent TextNodes are equal", () { diff --git a/super_editor/test/super_editor/super_editor_document_tree_test.dart b/super_editor/test/super_editor/super_editor_document_tree_test.dart new file mode 100644 index 0000000000..aaba922c67 --- /dev/null +++ b/super_editor/test/super_editor/super_editor_document_tree_test.dart @@ -0,0 +1,39 @@ +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 document trees >", () { + testWidgetsOnAllPlatforms("displays child nodes", (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.")), + GroupNode("2", [ + ParagraphNode(id: "2.1", text: AttributedText("Paragraph before the second level of embedding.")), + GroupNode("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("deleteme.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..76f7a43b0d 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: [ + GroupNode("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: [ + GroupNode("1", [ + ParagraphNode(id: "2", text: AttributedText(text)), + ]), + HorizontalRuleNode(id: "3"), + GroupNode("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."; @@ -1682,12 +1738,20 @@ void _expectTextEditingValue({ required String expectedTextWithSelection, required TextEditingValue actualTextEditingValue, }) { + print("Expected text with selection: '$expectedTextWithSelection'"); + print("Actual text editing value: '${actualTextEditingValue.text}'"); + print("Actual selection: ${actualTextEditingValue.selection}"); final selectionStartIndex = expectedTextWithSelection.indexOf("|"); - final selectionEndIndex = - expectedTextWithSelection.indexOf("|", selectionStartIndex + 1) - 1; // -1 to account for the selection start "|" + final selectionEndIndex = expectedTextWithSelection.characters.where((c) => c == "|").length > 1 + ? expectedTextWithSelection.indexOf("|", selectionStartIndex + 1) - 1 // -1 to account for the selection start "|" + : selectionStartIndex; final expectedText = expectedTextWithSelection.replaceAll("|", ""); + print("selectionStartIndex: $selectionStartIndex, selectionEndIndex: $selectionEndIndex"); final expectedSelection = TextSelection(baseOffset: selectionStartIndex, extentOffset: selectionEndIndex); + // expect(expectedText, actualTextEditingValue.text); + // expect(expectedSelection, actualTextEditingValue.selection); + expect( actualTextEditingValue, TextEditingValue(text: expectedText, selection: expectedSelection), diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 83edb07086..f5e4c92228 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -1252,7 +1252,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 e33d1fcfd6..5b6c211908 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -1016,7 +1016,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; } @@ -1047,7 +1051,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; }