Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor] - Make all DocumentNodes immutable (Resolves #2166) #2384

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
19 changes: 12 additions & 7 deletions super_editor/clones/quill/lib/editor/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,19 @@ class ClearTextAttributionsCommand extends EditCommand {
resizeSpansToFitInRange: true,
);
for (final span in spans) {
node.text = AttributedText(
node.text.text,
node.text.spans.copy()
..removeAttribution(
attributionToRemove: span.attribution,
start: span.start,
end: span.end,
document.replaceNode(
oldNode: node,
newNode: node.copyTextNodeWith(
text: AttributedText(
node.text.text,
node.text.spans.copy()
..removeAttribution(
attributionToRemove: span.attribution,
start: span.start,
end: span.end,
),
),
),
);

executor.logChanges([
Expand Down
83 changes: 54 additions & 29 deletions super_editor/lib/src/core/document.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,28 @@ abstract class Document implements Iterable<DocumentNode> {
/// given [node] in this [Document], or null if the given [node]
/// is the first node, or the given [node] does not exist in this
/// [Document].
@Deprecated("Use getNodeBeforeById() instead")
DocumentNode? getNodeBefore(DocumentNode node);

/// Returns the [DocumentNode] that appears immediately before the
/// node with the given [nodeId] in this [Document], or `null` if
/// the matching node is the first node in the document, or no such
/// node exists.
DocumentNode? getNodeBeforeById(String nodeId);

/// Returns the [DocumentNode] that appears immediately after the
/// given [node] in this [Document], or null if the given [node]
/// is the last node, or the given [node] does not exist in this
/// [Document].
@Deprecated("Use getNodeAfterById() instead")
DocumentNode? getNodeAfter(DocumentNode node);

/// Returns the [DocumentNode] that appears immediately after the
/// node with the given [nodeId] in this [Document], or `null` if
/// the matching node is the last node in the document, or no such
/// node exists.
DocumentNode? getNodeAfterById(String nodeId);

/// Returns the [DocumentNode] at the given [position], or [null] if
/// no such node exists in this [Document].
DocumentNode? getNode(DocumentPosition position);
Expand Down Expand Up @@ -308,14 +322,43 @@ class DocumentPosition {
}

/// A single content node within a [Document].
abstract class DocumentNode implements ChangeNotifier {
@immutable
abstract class DocumentNode {
DocumentNode({
Map<String, dynamic>? metadata,
}) : _metadata = metadata ?? {};

/// Adds [addedMetadata] to this nodes [metadata].
///
/// This protected method is intended to be used only during constructor
/// initialization by subclasses, so that subclasses can inject needed metadata
/// during construction time. This special method is provided because [DocumentNode]s
/// are otherwise immutable.
///
/// For example, a `ParagraphNode` might need to ensure that its block type
/// metadata is set to `paragraphAttribution`:
///
/// ParagraphNode({
/// required super.id,
/// required super.text,
/// this.indent = 0,
/// super.metadata,
/// }) {
/// if (getMetadataValue("blockType") == null) {
/// initAddToMetadata({"blockType": paragraphAttribution});
/// }
/// }
///
@protected
void initAddToMetadata(Map<String, dynamic> addedMetadata) {
_metadata.addAll(addedMetadata);
}

/// ID that is unique within a [Document].
String get id;

bool get isDeletable => _metadata[NodeMetadata.isDeletable] != false;

/// Returns the [NodePosition] that corresponds to the beginning
/// of content in this node.
///
Expand Down Expand Up @@ -375,49 +418,31 @@ abstract class DocumentNode implements ChangeNotifier {
}

/// Returns all metadata attached to this [DocumentNode].
Map<String, dynamic> get metadata => _metadata;
Map<String, dynamic> get metadata => Map.from(_metadata);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to create a new map that copies all items each time? If we just want to prevent modifications, could we use UnmodifiableMapView instead?

From the docs:

View of a Map that disallow modifying the map.

A wrapper around a Map that forwards all members to the map provided in the constructor, except for operations
that modify the map. Modifying operations throw instead.


final Map<String, dynamic> _metadata;

/// Sets all metadata for this [DocumentNode], removing all
/// existing values.
set metadata(Map<String, dynamic>? newMetadata) {
if (const DeepCollectionEquality().equals(_metadata, newMetadata)) {
return;
}

_metadata.clear();
if (newMetadata != null) {
_metadata.addAll(newMetadata);
}
notifyListeners();
}

/// Returns `true` if this node has a non-null metadata value for
/// the given metadata [key], and returns `false`, otherwise.
bool hasMetadataValue(String key) => _metadata[key] != null;

/// Returns this node's metadata value for the given [key].
dynamic getMetadataValue(String key) => _metadata[key];

/// Sets this node's metadata value for the given [key] to the given
/// [value], and notifies node listeners that a change has occurred.
void putMetadataValue(String key, dynamic value) {
if (_metadata[key] == value) {
return;
}
/// Returns a copy of this [DocumentNode] with [newProperties] added to
/// the node's metadata.
///
/// If [newProperties] contains keys that already exist in this node's
/// metadata, the existing properties are overwritten by [newProperties].
DocumentNode copyWithAddedMetadata(Map<String, dynamic> newProperties);

_metadata[key] = value;
notifyListeners();
}
/// Returns a copy of this [DocumentNode], replacing its existing
/// metadata with [newMetadata].
DocumentNode copyAndReplaceMetadata(Map<String, dynamic> newMetadata);

/// Returns a copy of this node's metadata.
Map<String, dynamic> copyMetadata() => Map.from(_metadata);

DocumentNode copy();

bool get isDeletable => _metadata[NodeMetadata.isDeletable] != false;

@override
bool operator ==(Object other) =>
identical(this, other) ||
Expand Down
58 changes: 45 additions & 13 deletions super_editor/lib/src/core/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,7 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
}) : _nodes = nodes ?? [] {
_refreshNodeIdCaches();

_latestNodesSnapshot = _nodes.map((node) => node.copy()).toList();
_latestNodesSnapshot = List.from(_nodes);
}

/// Creates an [Document] with a single [ParagraphNode].
Expand Down Expand Up @@ -1153,13 +1153,23 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable

@override
DocumentNode? getNodeBefore(DocumentNode node) {
final nodeIndex = getNodeIndexById(node.id);
return getNodeBeforeById(node.id);
}

@override
DocumentNode? getNodeBeforeById(String nodeId) {
final nodeIndex = getNodeIndexById(nodeId);
return nodeIndex > 0 ? getNodeAt(nodeIndex - 1) : null;
}

@override
DocumentNode? getNodeAfter(DocumentNode node) {
final nodeIndex = getNodeIndexById(node.id);
return getNodeAfterById(node.id);
}

@override
DocumentNode? getNodeAfterById(String nodeId) {
final nodeIndex = getNodeIndexById(nodeId);
return nodeIndex >= 0 && nodeIndex < _nodes.length - 1 ? getNodeAt(nodeIndex + 1) : null;
}

Expand Down Expand Up @@ -1196,20 +1206,20 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable

/// Inserts [newNode] immediately before the given [existingNode].
void insertNodeBefore({
required DocumentNode existingNode,
required String existingNodeId,
required DocumentNode newNode,
}) {
final nodeIndex = _nodes.indexOf(existingNode);
final nodeIndex = getNodeIndexById(existingNodeId);
_nodes.insert(nodeIndex, newNode);
_refreshNodeIdCaches();
}

/// Inserts [newNode] immediately after the given [existingNode].
void insertNodeAfter({
required DocumentNode existingNode,
required String existingNodeId,
required DocumentNode newNode,
}) {
final nodeIndex = _nodes.indexOf(existingNode);
final nodeIndex = getNodeIndexById(existingNodeId);
if (nodeIndex >= 0 && nodeIndex < _nodes.length) {
_nodes.insert(nodeIndex + 1, newNode);
_refreshNodeIdCaches();
Expand All @@ -1235,14 +1245,17 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
}

/// Deletes the given [node] from the [Document].
bool deleteNode(DocumentNode node) {
bool deleteNode(String nodeId) {
bool isRemoved = false;

isRemoved = _nodes.remove(node);
if (isRemoved) {
_refreshNodeIdCaches();
final index = getNodeIndexById(nodeId);
if (index < 0) {
return false;
}

_nodes.removeAt(index);
_refreshNodeIdCaches();

return isRemoved;
}

Expand All @@ -1263,13 +1276,14 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
}

/// Replaces the given [oldNode] with the given [newNode]
@Deprecated("Use replaceNodeById() instead")
void replaceNode({
required DocumentNode oldNode,
required DocumentNode newNode,
}) {
final index = _nodes.indexOf(oldNode);

if (index != -1) {
if (index >= 0) {
_nodes.removeAt(index);
_nodes.insert(index, newNode);
_refreshNodeIdCaches();
Expand All @@ -1278,6 +1292,24 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
}
}

/// Replaces the node with the given [nodeId] with the given [newNode].
///
/// Throws an exception if no node exists with the given [nodeId].
void replaceNodeById(
String nodeId,
DocumentNode newNode,
) {
final index = getNodeIndexById(nodeId);

if (index >= 0) {
_nodes.removeAt(index);
_nodes.insert(index, newNode);
_refreshNodeIdCaches();
} else {
throw Exception('Could not find node with ID: $nodeId');
}
}

/// Returns [true] if the content of the [other] [Document] is equivalent
/// to the content of this [Document].
///
Expand Down Expand Up @@ -1332,7 +1364,7 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
void reset() {
_nodes
..clear()
..addAll(_latestNodesSnapshot.map((node) => node.copy()).toList());
..addAll(_latestNodesSnapshot);
_refreshNodeIdCaches();

_didReset = true;
Expand Down
9 changes: 5 additions & 4 deletions super_editor/lib/src/default_editor/blockquote.dart
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,10 @@ class SplitBlockquoteCommand extends EditCommand {
final endText = splitPosition.offset < text.length ? text.copyText(splitPosition.offset) : AttributedText();

// Change the current node's content to just the text before the caret.
// TODO: figure out how node changes should work in terms of
// a DocumentEditorTransaction (#67)
blockquote.text = startText;
document.replaceNodeById(
blockquote.id,
blockquote.copyParagraphWith(text: startText),
);

// Create a new node that will follow the current node. Set its text
// to the text that was removed from the current node.
Expand All @@ -417,7 +418,7 @@ class SplitBlockquoteCommand extends EditCommand {

// Insert the new node after the current node.
document.insertNodeAfter(
existingNode: node,
existingNodeId: node.id,
newNode: newNode,
);

Expand Down
1 change: 1 addition & 0 deletions super_editor/lib/src/default_editor/box_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import '../core/document_layout.dart';
final _log = Logger(scope: 'box_component.dart');

/// Base implementation for a [DocumentNode] that only supports [UpstreamDownstreamNodeSelection]s.
@immutable
abstract class BlockNode extends DocumentNode {
BlockNode({
Map<String, dynamic>? metadata,
Expand Down
Loading
Loading