diff --git a/super_editor/.run/Demo_ Components in Components.run.xml b/super_editor/.run/Demo_ Components in Components.run.xml new file mode 100644 index 0000000000..a56b8e9308 --- /dev/null +++ b/super_editor/.run/Demo_ Components in Components.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file 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..1b21ebef92 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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..56c575eea8 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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..24e5263472 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // This builder can work with the standard task view model, so // we'll defer to the standard task builder. return null; diff --git a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart index bff74c71ff..43e208c957 100644 --- a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart +++ b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart @@ -182,8 +182,13 @@ class SpellingErrorParagraphComponentBuilder implements ComponentBuilder { final UnderlineStyle underlineStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - final viewModel = ParagraphComponentBuilder().createViewModel(document, node) as ParagraphComponentViewModel?; + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, + Document document, + DocumentNode node, + ) { + final viewModel = + ParagraphComponentBuilder().createViewModel(context, document, node) as ParagraphComponentViewModel?; if (viewModel == null) { return null; } diff --git a/super_editor/example/lib/main_components_in_components.dart b/super_editor/example/lib/main_components_in_components.dart new file mode 100644 index 0000000000..bbd3650cb1 --- /dev/null +++ b/super_editor/example/lib/main_components_in_components.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + runApp( + MaterialApp( + home: _ComponentsInComponentsDemoScreen(), + ), + ); +} + +class _ComponentsInComponentsDemoScreen extends StatefulWidget { + const _ComponentsInComponentsDemoScreen({super.key}); + + @override + State<_ComponentsInComponentsDemoScreen> createState() => _ComponentsInComponentsDemoScreenState(); +} + +class _ComponentsInComponentsDemoScreenState extends State<_ComponentsInComponentsDemoScreen> { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is a demo of a Banner component."), + metadata: { + NodeMetadata.blockType: header1Attribution, + }, + ), + _BannerNode(id: "2", children: [ + ParagraphNode( + id: "3", + text: AttributedText("Hello, Banner!"), + metadata: { + NodeMetadata.blockType: header1Attribution, + }, + ), + ParagraphNode( + id: "4", + text: AttributedText("This is a banner, which can contain any other blocks you want"), + ), + ]), + ParagraphNode( + id: "5", + text: AttributedText("This is after the banner component."), + ), + ], + ), + composer: MutableDocumentComposer(), + ); + } + + @override + void dispose() { + _editor.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SuperEditor( + editor: _editor, + componentBuilders: [ + _BannerComponentBuilder(), + ...defaultComponentBuilders, + ], + ), + ); + } +} + +class _BannerNode extends DocumentNode { + _BannerNode({ + required this.id, + required this.children, + }); + + @override + final String id; + + final List children; + + @override + NodePosition get beginningPosition => CompositeNodePosition( + children.first.id, + children.first.beginningPosition, + ); + + @override + NodePosition get endPosition => CompositeNodePosition( + children.last.id, + children.last.endPosition, + ); + + @override + bool containsPosition(Object position) { + if (position is! CompositeNodePosition) { + return false; + } + + for (final child in children) { + if (child.id == position.childNodeId) { + return child.containsPosition(position.childNodePosition); + } + } + + return false; + } + + @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}'); + } + + final index1 = int.parse(position1.childNodeId); + final index2 = int.parse(position2.childNodeId); + + if (index1 == index2) { + return position1.childNodePosition == + children[index1].selectUpstreamPosition(position1.childNodePosition, position2.childNodePosition) + ? position1 + : position2; + } + + return index1 < index2 ? position1 : position2; + } + + @override + NodePosition selectDownstreamPosition(NodePosition position1, NodePosition position2) { + final upstream = selectUpstreamPosition(position1, position2); + return upstream == position1 ? position2 : position1; + } + + @override + NodeSelection computeSelection({required NodePosition base, required NodePosition extent}) { + assert(base is CompositeNodePosition); + assert(extent is CompositeNodePosition); + + return BannerNodeSelection( + base: base as CompositeNodePosition, + extent: extent as CompositeNodePosition, + ); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + // TODO: implement copyWithAddedMetadata + throw UnimplementedError(); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + // TODO: implement copyAndReplaceMetadata + throw UnimplementedError(); + } + + @override + String? copyContent(NodeSelection selection) { + // TODO: implement copyContent + throw UnimplementedError(); + } +} + +class BannerNodeSelection implements NodeSelection { + const BannerNodeSelection.collapsed(CompositeNodePosition position) + : base = position, + extent = position; + + const BannerNodeSelection({ + required this.base, + required this.extent, + }); + + final CompositeNodePosition base; + + final CompositeNodePosition extent; +} + +class _BannerComponentBuilder implements ComponentBuilder { + @override + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext presenterContext, + Document document, + DocumentNode node, + ) { + if (node is! _BannerNode) { + return null; + } + + return _BannerViewModel( + nodeId: node.id, + children: node.children.map((childNode) => presenterContext.createViewModel(childNode)!).toList(), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! _BannerViewModel) { + return null; + } + + final childrenAndKeys = componentViewModel.children + .map( + (childViewModel) => componentContext.buildChildComponent(childViewModel), + ) + .toList(growable: false); + + print("Building a _BannerComponent - banner key: ${componentContext.componentKey}"); + print(" - child keys: ${childrenAndKeys.map((x) => x.$1)}"); + return _BannerComponent( + key: componentContext.componentKey, + // childComponentIds: [], + childComponentKeys: childrenAndKeys.map((childAndKey) => childAndKey.$1).toList(growable: false), + children: [ + for (final child in childrenAndKeys) // + child.$2, + ], + ); + } +} + +class _BannerViewModel extends SingleColumnLayoutComponentViewModel { + _BannerViewModel({ + required super.nodeId, + super.createdAt, + super.padding = EdgeInsets.zero, + super.maxWidth, + required this.children, + }); + + final List children; + + @override + SingleColumnLayoutComponentViewModel copy() { + return _BannerViewModel( + nodeId: nodeId, + createdAt: createdAt, + padding: padding, + maxWidth: maxWidth, + children: List.from(children), + ); + } +} + +class _BannerComponent extends StatefulWidget { + const _BannerComponent({ + super.key, + // required this.childComponentIds, + required this.childComponentKeys, + required this.children, + }); + + // final List childComponentIds; + + final List> childComponentKeys; + + final List children; + + @override + State<_BannerComponent> createState() => _BannerComponentState(); +} + +class _BannerComponentState extends State<_BannerComponent> with ProxyDocumentComponent<_BannerComponent> { + @override + final childDocumentComponentKey = GlobalKey(debugLabel: 'banner-internal-column'); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(8), + ), + child: ColumnDocumentComponent( + key: childDocumentComponentKey, + // childComponentIds: widget.childComponentIds, + childComponentKeys: widget.childComponentKeys, + children: widget.children, + ), + ), + ); + } +} diff --git a/super_editor/example_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart index 5d7772c971..047b6b3ea3 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 9dcc276128..424726db77 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -20,7 +20,8 @@ class BlockquoteComponentBuilder implements ComponentBuilder { const BlockquoteComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart index d3572a7da9..ecd76dbed0 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart @@ -154,6 +154,8 @@ class DocumentImeInputClient extends TextInputConnectionDecorator with TextInput editorImeLog.fine( "Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have:"); editorImeLog.fine("$newValue"); + print("Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have:"); + print("$newValue"); imeConnection.value?.setEditingState(newValue); } else { editorImeLog.fine("Ignoring new TextEditingValue because it's the same as the existing one: $newValue"); 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..f3bf1ac63a 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 @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/column_component.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; @@ -67,6 +68,8 @@ class DocumentImeSerializer { selectedNodes.clear(); selectedNodes.addAll(_doc.getNodesInContentOrder(selection)); + print("Serializing document to IME..."); + print(" - selected nodes: $selectedNodes"); for (int i = 0; i < selectedNodes.length; i += 1) { // Append a newline character before appending another node's text. // @@ -79,6 +82,10 @@ class DocumentImeSerializer { } final node = selectedNodes[i]; + // FIXME: I think we need nodes to serialize themselves, so that a Banner, Table, + // etc can serialize. But document structure probably shouldn't depend on IME behavior, + // so we might want an interface for ImeSerializable. If that's implemented, we call it, + // and if it's not implemented, we do the "~" block representation. if (node is! TextNode) { buffer.write('~'); characterCount += 1; @@ -351,7 +358,13 @@ class DocumentImeSerializer { throw Exception("No such document position in the IME content: $docPosition"); } - final nodePosition = docPosition.nodePosition; + var nodePosition = docPosition.nodePosition; + + // If the node position is a composite node, recursively dig into that position until + // we have a leaf-node position, such as a TextNodePosition or an UpstreamDownstreamNodePosition. + while (nodePosition is CompositeNodePosition) { + nodePosition = nodePosition.childNodePosition; + } if (nodePosition is UpstreamDownstreamNodePosition) { if (nodePosition.affinity == TextAffinity.upstream) { @@ -368,7 +381,7 @@ class DocumentImeSerializer { } if (nodePosition is TextNodePosition) { - return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); + return TextPosition(offset: imeRange.start + nodePosition.offset); } 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 3d3aadff66..942e56d20e 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -68,7 +68,8 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { const HorizontalRuleComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { 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 6a0c8d2619..999553e9e7 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -108,7 +108,8 @@ class ImageComponentBuilder implements ComponentBuilder { const ImageComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { 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..0b63dd4508 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 @@ -208,6 +208,7 @@ class _SingleColumnDocumentLayoutState extends State } final componentKey = _topToBottomComponentKeys.first; + print("_isAboveStartOfContent - key: $componentKey"); final componentBox = componentKey.currentContext!.findRenderObject() as RenderBox; final offsetAtComponent = _componentOffset(componentBox, documentOffset); @@ -405,6 +406,7 @@ class _SingleColumnDocumentLayoutState extends State final componentOverlap = _getLocalOverlapWithComponent(region, component); if (componentOverlap != null) { + print("Found overlapping component (index $i): $componentOverlap"); editorLayoutLog.fine(' - drag intersects: $componentKey}'); editorLayoutLog.fine(' - intersection: $componentOverlap'); final componentBaseOffset = _componentOffset( @@ -422,6 +424,7 @@ class _SingleColumnDocumentLayoutState extends State // Because we're iterating through components from top to bottom, the // first intersecting component that we find must be the top node of // the selected area. + print("This is the top node ($i)"); topNodeId = _componentKeysToNodeIds[componentKey]; topNodeBasePosition = _getNodePositionForComponentOffset(component, componentBaseOffset); topNodeExtentPosition = _getNodePositionForComponentOffset(component, componentExtentOffset); @@ -430,6 +433,7 @@ class _SingleColumnDocumentLayoutState extends State // intersection that we find. This way, when the iteration ends, // the last bottom node that we assigned must be the actual bottom // node within the selected area. + print("Updating bottom node with component $i position"); bottomNodeId = _componentKeysToNodeIds[componentKey]; bottomNodeBasePosition = _getNodePositionForComponentOffset(component, componentBaseOffset); bottomNodeExtentPosition = _getNodePositionForComponentOffset(component, componentExtentOffset); @@ -935,7 +939,7 @@ class _PresenterComponentBuilderState extends State<_PresenterComponentBuilder> /// Builds a component widget for the given [componentViewModel] and /// binds it to the given [componentKey]. /// -/// The specific widget that's build is determined by the given +/// The specific widget that's built is determined by the given /// [componentBuilders]. The component widget is rebuilt whenever the /// given [presenter] reports that the class _Component extends StatelessWidget { @@ -973,7 +977,28 @@ class _Component extends StatelessWidget { final componentContext = SingleColumnDocumentComponentContext( context: context, componentKey: componentKey, + buildChildComponent: _createChildBuilder(context), ); + + final component = _buildComponent( + componentContext, + componentBuilders, + componentViewModel, + showDebugPaint: showDebugPaint, + ); + if (component != null) { + return component; + } + + return const SizedBox(); + } + + Widget? _buildComponent( + SingleColumnDocumentComponentContext componentContext, + List componentBuilders, + SingleColumnLayoutComponentViewModel componentViewModel, { + bool showDebugPaint = false, + }) { for (final componentBuilder in componentBuilders) { var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { @@ -993,7 +1018,31 @@ class _Component extends StatelessWidget { return showDebugPaint ? _wrapWithDebugWidget(component) : component; } } - return const SizedBox(); + + return null; + } + + ComponentWidgetBuilder _createChildBuilder(BuildContext context) { + return (SingleColumnLayoutComponentViewModel componentViewModel) { + final childComponentKey = GlobalKey(); + + return ( + childComponentKey, + _buildComponent( + SingleColumnDocumentComponentContext( + context: context, + // FIXME: Normally this key is tied to a node and is cached. But child components don't have nodes... + componentKey: childComponentKey, + // Recursively generate functions to build deeper and deeper children. + buildChildComponent: _createChildBuilder(context), + ), + componentBuilders, + componentViewModel, + showDebugPaint: showDebugPaint, + ) ?? + const SizedBox(), + ); + }; } Widget _wrapWithDebugWidget(Widget component) { 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 86234d4c10..09e33f384f 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 @@ -1,5 +1,4 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/core/document.dart'; @@ -14,6 +13,7 @@ class SingleColumnDocumentComponentContext { const SingleColumnDocumentComponentContext({ required this.context, required this.componentKey, + required this.buildChildComponent, }); /// The [BuildContext] for the parent of the [DocumentComponent] @@ -26,8 +26,15 @@ class SingleColumnDocumentComponentContext { /// The [componentKey] is used by the [DocumentLayout] to query for /// node-specific information, like node positions and selections. final GlobalKey componentKey; + + /// Builds a child [DocumentComponent] for the component that owns this context. + final ComponentWidgetBuilder buildChildComponent; } +/// Finds and creates the component widget that presents the given [node]. +typedef ComponentWidgetBuilder = (GlobalKey, Widget) Function( + SingleColumnLayoutComponentViewModel viewModel); + /// Produces [SingleColumnLayoutViewModel]s to be displayed by a /// [SingleColumnDocumentLayout]. /// @@ -164,15 +171,10 @@ class SingleColumnLayoutPresenter { if (newViewModel == null) { // The document changed. All view models were invalidated. Create a // new base document view model. + final presenterContext = PresenterContext(_document, _componentBuilders); final viewModels = []; for (final node in _document) { - SingleColumnLayoutComponentViewModel? viewModel; - for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, node); - if (viewModel != null) { - break; - } - } + final viewModel = presenterContext.createViewModel(node); if (viewModel == null) { throw Exception("Couldn't find styler to create component for document node: ${node.runtimeType}"); } @@ -361,12 +363,18 @@ typedef ViewModelChangeCallback = void Function({ required List removedComponents, }); -/// Creates view models and components to display various [DocumentNode]s -/// in a [Document]. +/// Creates view models and components to display various [DocumentNode]s in a [Document]. abstract class ComponentBuilder { - /// Produces a [SingleColumnLayoutComponentViewModel] with default styles for the given + /// Creates 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); + /// + /// A [PresenterContext] is provided so that a component with children can create view + /// models for their children, too, and include those in the returned view model. + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext presenterContext, + Document document, + DocumentNode node, + ); /// Creates a visual component that renders the given [viewModel], /// or returns `null` if this builder doesn't apply to the given [viewModel]. @@ -381,7 +389,27 @@ abstract class ComponentBuilder { /// See [ComponentContext] for expectations about how to use the context /// to build a component widget. Widget? createComponent( - SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel); + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ); +} + +/// A context provided to [ComponentBuilder]s when constructing view models. +class PresenterContext { + const PresenterContext(this._document, this._componentBuilders); + + final Document _document; + final List _componentBuilders; + + SingleColumnLayoutComponentViewModel? createViewModel(DocumentNode node) { + for (final builder in _componentBuilders) { + final viewModel = builder.createViewModel(this, _document, node); + if (viewModel != null) { + return viewModel; + } + } + return null; + } } /// A single phase of style rules, which are applied in a pipeline to diff --git a/super_editor/lib/src/default_editor/layout_single_column/column_component.dart b/super_editor/lib/src/default_editor/layout_single_column/column_component.dart new file mode 100644 index 0000000000..debaa98140 --- /dev/null +++ b/super_editor/lib/src/default_editor/layout_single_column/column_component.dart @@ -0,0 +1,479 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; + +/// A [DocumentComponent] that presents other components, within a column. +class ColumnDocumentComponent extends StatefulWidget { + const ColumnDocumentComponent({ + super.key, + // required this.childComponentIds, + required this.childComponentKeys, + required this.children, + }); + + // final List childComponentIds; + + final List> childComponentKeys; + + final List children; + + @override + State createState() => _ColumnDocumentComponentState(); +} + +class _ColumnDocumentComponentState extends State + with DocumentComponent { + @override + NodePosition getBeginningPosition() { + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "0", + // widget.childComponentIds.first, + widget.childComponentKeys.first.currentState!.getBeginningPosition(), + ); + } + + @override + NodePosition getEndPosition() { + print("getEndPosition() - key: ${widget.childComponentKeys.last}"); + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "${widget.children.length - 1}", + // widget.childComponentIds.last, + widget.childComponentKeys.last.currentState!.getEndPosition(), + ); + } + + @override + NodePosition getBeginningPositionNearX(double x) { + return widget.childComponentKeys.first.currentState!.getBeginningPositionNearX(x); + } + + @override + NodePosition getEndPositionNearX(double x) { + return widget.childComponentKeys.last.currentState!.getEndPositionNearX(x); + } + + @override + MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { + print("getDesiredCursorAtOffset() - local offset: $localOffset"); + final childIndexNearestToOffset = _getIndexOfChildNearestTo(localOffset); + final childOffset = _projectColumnOffsetToChildSpace(localOffset, childIndexNearestToOffset); + print(" - offset in child ($childIndexNearestToOffset): $childOffset"); + + return widget.childComponentKeys[childIndexNearestToOffset].currentState!.getDesiredCursorAtOffset(childOffset); + } + + @override + NodePosition? getPositionAtOffset(Offset localOffset) { + // TODO: Change all implementations of getPositionAtOffset to be exact, not nearest - but this first + // requires updating the gesture offset lookups. + print("Column component - getPositionAtOffset() - local offset: $localOffset"); + if (localOffset.dy < 0) { + return CompositeNodePosition( + // TODO: use real IDs, not just index. + "0", + widget.childComponentKeys.first.currentState!.getBeginningPosition(), + ); + } + + final columnBox = _columnBox; + if (localOffset.dy > columnBox.size.height) { + return CompositeNodePosition( + // TODO: use real IDs, not just index. + "${widget.children.length - 1}", + widget.childComponentKeys.last.currentState!.getEndPosition(), + ); + } + + final childIndex = _getIndexOfChildNearestTo(localOffset); + final childOffset = _projectColumnOffsetToChildSpace(localOffset, childIndex); + + print(" - Returning position at offset for child $childIndex, at child offset: $childOffset"); + return CompositeNodePosition( + // TODO: use real IDs, not just index. + "$childIndex", + widget.childComponentKeys[childIndex].currentState!.getPositionAtOffset(childOffset)!, + ); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + if (nodePosition is! CompositeNodePosition) { + throw Exception( + "Tried get edge near position within a ColumnDocumentComponent with invalid type of node position: $nodePosition"); + } + + return _getChildComponentAtPosition(nodePosition).getEdgeForPosition(nodePosition.childNodePosition); + } + + @override + Offset getOffsetForPosition(NodePosition nodePosition) { + if (nodePosition is! CompositeNodePosition) { + throw Exception( + "Tried get offset for position within a ColumnDocumentComponent with invalid type of node position: $nodePosition"); + } + + final childIndex = _findChildIndexForPosition(nodePosition); + return _getChildComponentAtIndex(childIndex).getOffsetForPosition(nodePosition.childNodePosition); + } + + @override + Rect getRectForPosition(NodePosition nodePosition) { + if (nodePosition is! CompositeNodePosition) { + throw Exception( + "Tried get bounding rectangle for position within a ColumnDocumentComponent with invalid type of node position: $nodePosition"); + } + + return _getChildComponentAtIndex( + _findChildIndexForPosition(nodePosition), + ).getRectForPosition(nodePosition.childNodePosition); + } + + @override + Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { + if (baseNodePosition is! CompositeNodePosition || extentNodePosition is! CompositeNodePosition) { + throw Exception( + "Tried to select within a ColumnDocumentComponent with invalid position types - base: $baseNodePosition, extent: $extentNodePosition"); + } + + final baseIndex = int.parse(baseNodePosition.childNodeId); + + final extentIndex = int.parse(extentNodePosition.childNodeId); + final extentComponent = widget.childComponentKeys[extentIndex].currentState!; + + DocumentComponent topComponent; + final componentBoundingBoxes = []; + + // Collect bounding boxes for all selected components. + final columnComponentBox = context.findRenderObject() as RenderBox; + if (baseIndex == extentIndex) { + // Selection within a single node. + topComponent = extentComponent; + final componentOffsetInDocument = (topComponent.context.findRenderObject() as RenderBox) + .localToGlobal(Offset.zero, ancestor: columnComponentBox); + + final componentBoundingBox = extentComponent + .getRectForSelection( + baseNodePosition.childNodePosition, + extentNodePosition.childNodePosition, + ) + .translate( + componentOffsetInDocument.dx, + componentOffsetInDocument.dy, + ); + componentBoundingBoxes.add(componentBoundingBox); + } else { + // Selection across nodes. + final topNodeIndex = min(baseIndex, extentIndex); + final topColumnPosition = baseIndex < extentIndex ? baseNodePosition : extentNodePosition; + + final bottomNodeIndex = max(baseIndex, extentIndex); + final bottomColumnPosition = baseIndex < extentIndex ? extentNodePosition : baseNodePosition; + + for (int i = topNodeIndex; i <= bottomNodeIndex; ++i) { + final component = widget.childComponentKeys[i].currentState!; + final componentOffsetInColumnComponent = (component.context.findRenderObject() as RenderBox) + .localToGlobal(Offset.zero, ancestor: columnComponentBox); + + if (i == topNodeIndex) { + // This is the first node. The selection goes from + // startPosition to the end of the node. + final firstNodeEndPosition = component.getEndPosition(); + final selectionRectInComponent = component.getRectForSelection( + topColumnPosition.childNodePosition, + firstNodeEndPosition, + ); + final componentRectInDocument = selectionRectInComponent.translate( + componentOffsetInColumnComponent.dx, + componentOffsetInColumnComponent.dy, + ); + componentBoundingBoxes.add(componentRectInDocument); + } else if (i == bottomNodeIndex) { + // This is the last node. The selection goes from + // the beginning of the node to endPosition. + final lastNodeStartPosition = component.getBeginningPosition(); + final selectionRectInComponent = component.getRectForSelection( + lastNodeStartPosition, + bottomColumnPosition.childNodePosition, + ); + final componentRectInColumnLayout = selectionRectInComponent.translate( + componentOffsetInColumnComponent.dx, + componentOffsetInColumnComponent.dy, + ); + componentBoundingBoxes.add(componentRectInColumnLayout); + } else { + // This node sits between start and end. All content + // is selected. + final selectionRectInComponent = component.getRectForSelection( + component.getBeginningPosition(), + component.getEndPosition(), + ); + final componentRectInColumnLayout = selectionRectInComponent.translate( + componentOffsetInColumnComponent.dx, + componentOffsetInColumnComponent.dy, + ); + componentBoundingBoxes.add(componentRectInColumnLayout); + } + } + } + + // Combine all component boxes into one big bounding box. + Rect boundingBox = componentBoundingBoxes.first; + for (int i = 1; i < componentBoundingBoxes.length; ++i) { + boundingBox = boundingBox.expandToInclude(componentBoundingBoxes[i]); + } + + return boundingBox; + } + + @override + NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { + if (nodePosition is! CompositeNodePosition) { + throw Exception( + "Tried get position within a ColumnDocumentComponent with invalid type of node position: $nodePosition"); + } + + // TODO: implement getCollapsedSelectionAt + throw UnimplementedError(); + } + + @override + NodeSelection getSelectionBetween({required NodePosition basePosition, required NodePosition extentPosition}) { + if (basePosition is! CompositeNodePosition || extentPosition is! CompositeNodePosition) { + throw Exception( + "Tried to select within a ColumnDocumentComponent with invalid position types - base: $basePosition, extent: $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) { + if (currentPosition is! CompositeNodePosition) { + return null; + } + + final childIndex = _findChildIndexForPosition(currentPosition); + final child = _getChildComponentAtIndex(childIndex); + final upWithinChild = child.movePositionUp(currentPosition.childNodePosition); + if (upWithinChild != null) { + return currentPosition.moveWithinChild(upWithinChild); + } + + if (childIndex == 0) { + // Nothing above this child. + return null; + } + + // The next position up must be the ending position of the previous component. + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "${childIndex - 1}", + // widget.childComponentIds[childIndex - 1], + _getChildComponentAtIndex(childIndex - 1).getEndPosition(), + ); + } + + @override + NodePosition? movePositionDown(NodePosition currentPosition) { + if (currentPosition is! CompositeNodePosition) { + return null; + } + + final childIndex = _findChildIndexForPosition(currentPosition); + final child = _getChildComponentAtIndex(childIndex); + final downWithinChild = child.movePositionDown(currentPosition.childNodePosition); + if (downWithinChild != null) { + return currentPosition.moveWithinChild(downWithinChild); + } + + if (childIndex == widget.children.length - 1) { + // Nothing below this child. + return null; + } + + // The next position down must be the beginning position of the next component. + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "${childIndex + 1}", + // widget.childComponentIds[childIndex + 1], + _getChildComponentAtIndex(childIndex + 1).getBeginningPosition(), + ); + } + + @override + NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { + if (currentPosition is! CompositeNodePosition) { + return null; + } + + final childIndex = _findChildIndexForPosition(currentPosition); + final child = _getChildComponentAtIndex(childIndex); + final leftWithinChild = child.movePositionLeft(currentPosition.childNodePosition); + if (leftWithinChild != null) { + return currentPosition.moveWithinChild(leftWithinChild); + } + + if (childIndex == 0) { + // Nothing above this child. + return null; + } + + // The next position left must be the ending position of the previous component. + // TODO: This assumes left-to-right content ordering, which isn't true for some + // languages. Revisit this when/if we need RTL support for this behavior. + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "${childIndex - 1}", + // widget.childComponentIds[childIndex - 1], + _getChildComponentAtIndex(childIndex - 1).getEndPosition(), + ); + } + + @override + NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { + if (currentPosition is! CompositeNodePosition) { + return null; + } + + final childIndex = _findChildIndexForPosition(currentPosition); + final child = _getChildComponentAtIndex(childIndex); + final rightWithinChild = child.movePositionRight(currentPosition.childNodePosition); + if (rightWithinChild != null) { + return currentPosition.moveWithinChild(rightWithinChild); + } + + if (childIndex == widget.children.length - 1) { + // Nothing below this child. + return null; + } + + // The next position right must be the beginning position of the next component. + // TODO: This assumes left-to-right content ordering, which isn't true for some + // languages. Revisit this when/if we need RTL support for this behavior. + return CompositeNodePosition( + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + "${childIndex + 1}", + // widget.childComponentIds[childIndex + 1], + _getChildComponentAtIndex(childIndex + 1).getBeginningPosition(), + ); + } + + DocumentComponent _getChildComponentAtPosition(CompositeNodePosition columnPosition) { + final childIndex = int.parse(columnPosition.childNodeId); + return widget.childComponentKeys[childIndex].currentState!; + } + + DocumentComponent _getChildComponentAtIndex(int childIndex) { + return widget.childComponentKeys[childIndex].currentState!; + } + + int _findChildIndexForPosition(CompositeNodePosition position) { + for (int i = 0; i < widget.children.length; i += 1) { + // TODO: We're using ad hoc component IDs based on child index. Come up with robust solution. + if ("$i" == position.childNodeId) { + return i; + } + } + + return -1; + } + + int _getIndexOfChildNearestTo(Offset componentOffset) { + if (componentOffset.dy < 0) { + // Offset is above this component. Return the first item in the column. + return 0; + } + + final columnBox = context.findRenderObject() as RenderBox; + final componentHeight = columnBox.size.height; + if (componentOffset.dy > componentHeight) { + // The offset is below this component. Return the last item in the column. + return widget.children.length - 1; + } + + // The offset is vertically somewhere within this column. Return the child + // whose y-bounds contain this offset's y-value. + for (int i = 0; i < widget.children.length; i += 1) { + final childBox = widget.childComponentKeys[i].currentContext!.findRenderObject() as RenderBox; + final childBottomY = childBox.localToGlobal(Offset.zero, ancestor: columnBox).dy + childBox.size.height; + if (childBottomY >= componentOffset.dy) { + // Found the child that vertically contains the offset. Horizontal offset + // doesn't matter because we're looking for "nearest". + return i; + } + } + + throw Exception("Tried to find the child nearest to component offset ($componentOffset) but couldn't find one."); + } + + /// Given an offset that's relative to this column, finds where that same point sits + /// within the given child, and returns that offset local to the child coordinate system. + Offset _projectColumnOffsetToChildSpace(Offset columnOffset, int childIndex) { + return _getChildBoxAtIndex(childIndex).globalToLocal(columnOffset, ancestor: _columnBox); + } + + RenderBox get _columnBox => context.findRenderObject() as RenderBox; + + RenderBox _getChildBoxAtIndex(int childIndex) { + return widget.childComponentKeys[childIndex].currentContext!.findRenderObject() as RenderBox; + } + + @override + Widget build(BuildContext context) { + print("Composite component children: ${widget.children}"); + print("Child component keys: ${widget.childComponentKeys}"); + + return IgnorePointer( + child: Column( + children: widget.children, + ), + ); + } +} + +class CompositeNodePosition implements NodePosition { + const CompositeNodePosition(this.childNodeId, this.childNodePosition); + + final String childNodeId; + final NodePosition childNodePosition; + + CompositeNodePosition moveWithinChild(NodePosition newPosition) { + return CompositeNodePosition(childNodeId, newPosition); + } + + @override + bool isEquivalentTo(NodePosition other) { + return this == other; + } + + @override + String toString() => "[CompositeNodePosition] - $childNodeId -> $childNodePosition"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositeNodePosition && + runtimeType == other.runtimeType && + childNodeId == other.childNodeId && + childNodePosition == other.childNodePosition; + + @override + int get hashCode => childNodeId.hashCode ^ childNodePosition.hashCode; +} diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index f4818363ee..0abb5e6884 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -151,7 +151,8 @@ class ListItemComponentBuilder implements ComponentBuilder { const ListItemComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { 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 1d133ce11a..0a839c177e 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -104,7 +104,8 @@ class ParagraphComponentBuilder implements ComponentBuilder { const ParagraphComponentBuilder(); @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { if (node is! ParagraphNode) { return null; } @@ -159,6 +160,9 @@ class ParagraphComponentBuilder implements ComponentBuilder { editorLayoutLog.finer(' - not painting any text selection'); } + print( + "Building a ParagraphComponent (ID: ${componentViewModel.nodeId}) with component key: ${componentContext.componentKey}"); + return ParagraphComponent( key: componentContext.componentKey, viewModel: componentViewModel, @@ -291,6 +295,7 @@ class HintComponentBuilder extends ParagraphComponentBuilder { @override SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node, ) { @@ -314,7 +319,7 @@ class HintComponentBuilder extends ParagraphComponentBuilder { } return HintComponentViewModel.fromParagraphViewModel( - super.createViewModel(document, node)! as ParagraphComponentViewModel, + super.createViewModel(context, document, node)! as ParagraphComponentViewModel, hintText: hint, ); } diff --git a/super_editor/lib/src/default_editor/tables.dart b/super_editor/lib/src/default_editor/tables.dart new file mode 100644 index 0000000000..6bf8b74365 --- /dev/null +++ b/super_editor/lib/src/default_editor/tables.dart @@ -0,0 +1,295 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/column_component.dart'; + +class TableNode extends DocumentNode { + TableNode({ + required this.id, + this.flowDirection = TableFlowDirection.horizontalThenVertical, + required this.cells, + }); + + @override + final String id; + + final TableFlowDirection flowDirection; + + /// All cells in this table, keyed by row -> column -> cell index + final List>> cells; + + int get rowCount => cells.length; + + int get columnCount => cells.first.length; + + @override + NodePosition get beginningPosition => TableNodePosition( + row: 0, + column: 0, + nodeId: cells[0][0][0].id, + nodePosition: cells[0][0][0].beginningPosition, + ); + + @override + NodePosition get endPosition => TableNodePosition( + row: cells.length - 1, + column: cells.last.length - 1, + nodeId: cells.last.last.last.id, + nodePosition: cells.last.last.last.endPosition, + ); + + @override + bool containsPosition(Object position) { + if (position is! TableNodePosition) { + return false; + } + + if (position.row < 0 || position.row >= rowCount) { + return false; + } + + if (position.column < 0 || position.column >= columnCount) { + return false; + } + + return true; + } + + @override + NodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { + if (position1 is! TableNodePosition) { + throw Exception('Expected a TableNodePosition for position1 but received a ${position1.runtimeType}'); + } + if (position2 is! TableNodePosition) { + throw Exception('Expected a TableNodePosition for position2 but received a ${position2.runtimeType}'); + } + + if (position1.row != position2.row) { + return position1.row < position2.row ? position1 : position2; + } + + if (position1.column != position2.column) { + return position1.column < position2.column ? position1 : position2; + } + + // Both positions sit in the same cell. Report order based on position in child list. + final cellNodes = cells[position1.row][position1.column]; + + final position1Node = cellNodes.firstWhereOrNull((node) => node.id == position1.nodeId); + final position1Index = position1Node != null ? cellNodes.indexOf(position1Node) : -1; + + final position2Node = cellNodes.firstWhereOrNull((node) => node.id == position2.nodeId); + final position2Index = position2Node != null ? cellNodes.indexOf(position2Node) : -1; + + return position1Index <= position2Index ? position1 : position2; + } + + @override + NodePosition selectDownstreamPosition(NodePosition position1, NodePosition position2) { + final upstream = selectUpstreamPosition(position1, position2); + return upstream == position1 ? position2 : position1; + } + + @override + NodeSelection computeSelection({required NodePosition base, required NodePosition extent}) { + // TODO: implement computeSelection + throw UnimplementedError(); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + // TODO: implement copyWithAddedMetadata + throw UnimplementedError(); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + // TODO: implement copyAndReplaceMetadata + throw UnimplementedError(); + } + + @override + String? copyContent(NodeSelection selection) { + // TODO: implement copyContent + throw UnimplementedError(); + } +} + +enum TableFlowDirection { + verticalThenHorizontal, + horizontalThenVertical; +} + +class TableNodeSelection implements NodeSelection { + const TableNodeSelection.collapsed(TableNodePosition position) + : base = position, + extent = position; + + factory TableNodeSelection.inCell({ + required int row, + required int column, + required String baseNodeId, + required NodePosition baseNodePosition, + required String extentNodeId, + required NodePosition extentNodePosition, + }) { + return TableNodeSelection( + base: TableNodePosition( + row: row, + column: column, + nodeId: baseNodeId, + nodePosition: baseNodePosition, + ), + extent: TableNodePosition( + row: row, + column: column, + nodeId: extentNodeId, + nodePosition: extentNodePosition, + ), + ); + } + + const TableNodeSelection({ + required this.base, + required this.extent, + }); + + final TableNodePosition base; + + final TableNodePosition extent; +} + +class TableNodePosition implements NodePosition { + const TableNodePosition({ + required this.row, + required this.column, + required this.nodeId, + required this.nodePosition, + }); + + final int row; + final int column; + final String nodeId; + final NodePosition nodePosition; + + @override + bool isEquivalentTo(NodePosition other) { + if (other is! TableNodePosition) { + return false; + } + + return row == other.row && // + column == other.column && + nodeId == other.nodeId && + nodePosition == other.nodePosition; + } +} + +class TableComponentBuilder implements ComponentBuilder { + @override + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, + Document document, + DocumentNode node, + ) { + if (node is! TableNode) { + return null; + } + + return TableComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + // Create view models for every node within every cell in the table. + cells: node.cells + // For each row. + .map((row) => row + // For each column. + .map((column) => column + // For each node in the cell. + .map( + // Create a View Model for the node. + (node) => context.createViewModel(node), + ) + .nonNulls + .toList(growable: false)) + .toList(growable: false)) + .toList(growable: false)); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! TableComponentViewModel) { + return null; + } + + return TableDocumentComponent( + viewModel: componentViewModel, + componentBuilder: componentContext.buildChildComponent, + ); + } +} + +class TableComponentViewModel extends SingleColumnLayoutComponentViewModel { + TableComponentViewModel({ + required super.nodeId, + required super.createdAt, + super.padding = EdgeInsets.zero, + required this.cells, + }); + + // Keyed as cells[row][col]. + final List>> cells; + + @override + SingleColumnLayoutComponentViewModel copy() { + // TODO: implement copy + throw UnimplementedError(); + } +} + +class TableDocumentComponent extends StatefulWidget { + const TableDocumentComponent({ + super.key, + required this.viewModel, + required this.componentBuilder, + }); + + final TableComponentViewModel viewModel; + + // TODO: Consider moving this behavior into an InheritedWidget that we place in the document layout + final Widget? Function(SingleColumnLayoutComponentViewModel viewModel) componentBuilder; + + @override + State createState() => _TableDocumentComponentState(); +} + +class _TableDocumentComponentState extends State { + @override + Widget build(BuildContext context) { + return Table( + children: [ + for (int row = 0; row < widget.viewModel.cells.length; row += 1) // + TableRow( + children: _buildRow(row), + ), + ], + ); + } + + List _buildRow(int rowIndex) { + return widget // + .viewModel + .cells[rowIndex] + .map((cell) => _buildCell(cell)) + .toList(growable: false); + } + + Widget _buildCell(List cell) { + // TODO: pass needed stuff to the column component. + return ColumnDocumentComponent(); + } +} diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index d87434aaed..e61baae8f2 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -158,7 +158,7 @@ class TaskComponentBuilder implements ComponentBuilder { final Editor _editor; @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel(PresenterContext context, Document document, DocumentNode node) { 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 813529dce1..c6204a9221 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { return _UnknownViewModel( nodeId: node.id, createdAt: node.metadata[NodeMetadata.createdAt], diff --git a/super_editor/lib/src/super_reader/tasks.dart b/super_editor/lib/src/super_reader/tasks.dart index 36862bab71..e6f8bdc2ba 100644 --- a/super_editor/lib/src/super_reader/tasks.dart +++ b/super_editor/lib/src/super_reader/tasks.dart @@ -13,7 +13,7 @@ class ReadOnlyTaskComponentBuilder implements ComponentBuilder { const ReadOnlyTaskComponentBuilder(); @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + TaskComponentViewModel? createViewModel(PresenterContext context, Document document, DocumentNode node) { if (node is! TaskNode) { return null; } diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index c64beef012..bddcc9b024 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -45,6 +45,7 @@ export 'src/default_editor/layout_single_column/super_editor_dry_layout.dart'; export 'src/default_editor/list_items.dart'; export 'src/default_editor/multi_node_editing.dart'; export 'src/default_editor/paragraph.dart'; +export 'src/default_editor/layout_single_column/column_component.dart'; 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'; 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 eed18b5869..71fdaed61d 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { 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..a3dc9b30ab 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { return null; } diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 83edb07086..550ded006a 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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..93df8e6ecc 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { 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..71817846f4 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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..d907d4e8a1 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( + PresenterContext context, + Document document, + DocumentNode node, + ) { // 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..382337fcd0 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,15 @@ class _ListItemWithCustomStyleBuilder implements ComponentBuilder { final OrderedListNumeralStyle? numeralStyle; @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + SingleColumnLayoutComponentViewModel? createViewModel( + PresenterContext context, Document document, DocumentNode node) { 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(context, document, node); if (viewModel is UnorderedListItemComponentViewModel && dotStyle != null) { viewModel.dotStyle = dotStyle!;