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!;