Skip to content

Commit bca57df

Browse files
[SuperEditor] - Make all DocumentNodes immutable (Resolves #2166) (#2384)
1 parent a0e0897 commit bca57df

29 files changed

+1133
-737
lines changed

super_editor/clones/quill/lib/editor/editor.dart

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,14 +226,19 @@ class ClearTextAttributionsCommand extends EditCommand {
226226
resizeSpansToFitInRange: true,
227227
);
228228
for (final span in spans) {
229-
node.text = AttributedText(
230-
node.text.text,
231-
node.text.spans.copy()
232-
..removeAttribution(
233-
attributionToRemove: span.attribution,
234-
start: span.start,
235-
end: span.end,
229+
document.replaceNode(
230+
oldNode: node,
231+
newNode: node.copyTextNodeWith(
232+
text: AttributedText(
233+
node.text.text,
234+
node.text.spans.copy()
235+
..removeAttribution(
236+
attributionToRemove: span.attribution,
237+
start: span.start,
238+
end: span.end,
239+
),
236240
),
241+
),
237242
);
238243

239244
executor.logChanges([

super_editor/lib/src/core/document.dart

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,28 @@ abstract class Document implements Iterable<DocumentNode> {
5555
/// given [node] in this [Document], or null if the given [node]
5656
/// is the first node, or the given [node] does not exist in this
5757
/// [Document].
58+
@Deprecated("Use getNodeBeforeById() instead")
5859
DocumentNode? getNodeBefore(DocumentNode node);
5960

61+
/// Returns the [DocumentNode] that appears immediately before the
62+
/// node with the given [nodeId] in this [Document], or `null` if
63+
/// the matching node is the first node in the document, or no such
64+
/// node exists.
65+
DocumentNode? getNodeBeforeById(String nodeId);
66+
6067
/// Returns the [DocumentNode] that appears immediately after the
6168
/// given [node] in this [Document], or null if the given [node]
6269
/// is the last node, or the given [node] does not exist in this
6370
/// [Document].
71+
@Deprecated("Use getNodeAfterById() instead")
6472
DocumentNode? getNodeAfter(DocumentNode node);
6573

74+
/// Returns the [DocumentNode] that appears immediately after the
75+
/// node with the given [nodeId] in this [Document], or `null` if
76+
/// the matching node is the last node in the document, or no such
77+
/// node exists.
78+
DocumentNode? getNodeAfterById(String nodeId);
79+
6680
/// Returns the [DocumentNode] at the given [position], or [null] if
6781
/// no such node exists in this [Document].
6882
DocumentNode? getNode(DocumentPosition position);
@@ -308,14 +322,43 @@ class DocumentPosition {
308322
}
309323

310324
/// A single content node within a [Document].
311-
abstract class DocumentNode implements ChangeNotifier {
325+
@immutable
326+
abstract class DocumentNode {
312327
DocumentNode({
313328
Map<String, dynamic>? metadata,
314329
}) : _metadata = metadata ?? {};
315330

331+
/// Adds [addedMetadata] to this nodes [metadata].
332+
///
333+
/// This protected method is intended to be used only during constructor
334+
/// initialization by subclasses, so that subclasses can inject needed metadata
335+
/// during construction time. This special method is provided because [DocumentNode]s
336+
/// are otherwise immutable.
337+
///
338+
/// For example, a `ParagraphNode` might need to ensure that its block type
339+
/// metadata is set to `paragraphAttribution`:
340+
///
341+
/// ParagraphNode({
342+
/// required super.id,
343+
/// required super.text,
344+
/// this.indent = 0,
345+
/// super.metadata,
346+
/// }) {
347+
/// if (getMetadataValue("blockType") == null) {
348+
/// initAddToMetadata({"blockType": paragraphAttribution});
349+
/// }
350+
/// }
351+
///
352+
@protected
353+
void initAddToMetadata(Map<String, dynamic> addedMetadata) {
354+
_metadata.addAll(addedMetadata);
355+
}
356+
316357
/// ID that is unique within a [Document].
317358
String get id;
318359

360+
bool get isDeletable => _metadata[NodeMetadata.isDeletable] != false;
361+
319362
/// Returns the [NodePosition] that corresponds to the beginning
320363
/// of content in this node.
321364
///
@@ -380,49 +423,31 @@ abstract class DocumentNode implements ChangeNotifier {
380423
}
381424

382425
/// Returns all metadata attached to this [DocumentNode].
383-
Map<String, dynamic> get metadata => _metadata;
426+
Map<String, dynamic> get metadata => Map.from(_metadata);
384427

385428
final Map<String, dynamic> _metadata;
386429

387-
/// Sets all metadata for this [DocumentNode], removing all
388-
/// existing values.
389-
set metadata(Map<String, dynamic>? newMetadata) {
390-
if (const DeepCollectionEquality().equals(_metadata, newMetadata)) {
391-
return;
392-
}
393-
394-
_metadata.clear();
395-
if (newMetadata != null) {
396-
_metadata.addAll(newMetadata);
397-
}
398-
notifyListeners();
399-
}
400-
401430
/// Returns `true` if this node has a non-null metadata value for
402431
/// the given metadata [key], and returns `false`, otherwise.
403432
bool hasMetadataValue(String key) => _metadata[key] != null;
404433

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

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

415-
_metadata[key] = value;
416-
notifyListeners();
417-
}
444+
/// Returns a copy of this [DocumentNode], replacing its existing
445+
/// metadata with [newMetadata].
446+
DocumentNode copyAndReplaceMetadata(Map<String, dynamic> newMetadata);
418447

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

422-
DocumentNode copy();
423-
424-
bool get isDeletable => _metadata[NodeMetadata.isDeletable] != false;
425-
426451
@override
427452
bool operator ==(Object other) =>
428453
identical(this, other) ||

super_editor/lib/src/core/editor.dart

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,7 +1067,7 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
10671067
}) : _nodes = nodes ?? [] {
10681068
_refreshNodeIdCaches();
10691069

1070-
_latestNodesSnapshot = _nodes.map((node) => node.copy()).toList();
1070+
_latestNodesSnapshot = List.from(_nodes);
10711071
}
10721072

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

11541154
@override
11551155
DocumentNode? getNodeBefore(DocumentNode node) {
1156-
final nodeIndex = getNodeIndexById(node.id);
1156+
return getNodeBeforeById(node.id);
1157+
}
1158+
1159+
@override
1160+
DocumentNode? getNodeBeforeById(String nodeId) {
1161+
final nodeIndex = getNodeIndexById(nodeId);
11571162
return nodeIndex > 0 ? getNodeAt(nodeIndex - 1) : null;
11581163
}
11591164

11601165
@override
11611166
DocumentNode? getNodeAfter(DocumentNode node) {
1162-
final nodeIndex = getNodeIndexById(node.id);
1167+
return getNodeAfterById(node.id);
1168+
}
1169+
1170+
@override
1171+
DocumentNode? getNodeAfterById(String nodeId) {
1172+
final nodeIndex = getNodeIndexById(nodeId);
11631173
return nodeIndex >= 0 && nodeIndex < _nodes.length - 1 ? getNodeAt(nodeIndex + 1) : null;
11641174
}
11651175

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

11971207
/// Inserts [newNode] immediately before the given [existingNode].
11981208
void insertNodeBefore({
1199-
required DocumentNode existingNode,
1209+
required String existingNodeId,
12001210
required DocumentNode newNode,
12011211
}) {
1202-
final nodeIndex = _nodes.indexOf(existingNode);
1212+
final nodeIndex = getNodeIndexById(existingNodeId);
12031213
_nodes.insert(nodeIndex, newNode);
12041214
_refreshNodeIdCaches();
12051215
}
12061216

12071217
/// Inserts [newNode] immediately after the given [existingNode].
12081218
void insertNodeAfter({
1209-
required DocumentNode existingNode,
1219+
required String existingNodeId,
12101220
required DocumentNode newNode,
12111221
}) {
1212-
final nodeIndex = _nodes.indexOf(existingNode);
1222+
final nodeIndex = getNodeIndexById(existingNodeId);
12131223
if (nodeIndex >= 0 && nodeIndex < _nodes.length) {
12141224
_nodes.insert(nodeIndex + 1, newNode);
12151225
_refreshNodeIdCaches();
@@ -1235,14 +1245,17 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
12351245
}
12361246

12371247
/// Deletes the given [node] from the [Document].
1238-
bool deleteNode(DocumentNode node) {
1248+
bool deleteNode(String nodeId) {
12391249
bool isRemoved = false;
12401250

1241-
isRemoved = _nodes.remove(node);
1242-
if (isRemoved) {
1243-
_refreshNodeIdCaches();
1251+
final index = getNodeIndexById(nodeId);
1252+
if (index < 0) {
1253+
return false;
12441254
}
12451255

1256+
_nodes.removeAt(index);
1257+
_refreshNodeIdCaches();
1258+
12461259
return isRemoved;
12471260
}
12481261

@@ -1269,13 +1282,14 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
12691282
}
12701283

12711284
/// Replaces the given [oldNode] with the given [newNode]
1285+
@Deprecated("Use replaceNodeById() instead")
12721286
void replaceNode({
12731287
required DocumentNode oldNode,
12741288
required DocumentNode newNode,
12751289
}) {
12761290
final index = _nodes.indexOf(oldNode);
12771291

1278-
if (index != -1) {
1292+
if (index >= 0) {
12791293
_nodes.removeAt(index);
12801294
_nodes.insert(index, newNode);
12811295
_refreshNodeIdCaches();
@@ -1284,7 +1298,25 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
12841298
}
12851299
}
12861300

1287-
/// Returns `true` if the content of the [other] [Document] is equivalent
1301+
/// Replaces the node with the given [nodeId] with the given [newNode].
1302+
///
1303+
/// Throws an exception if no node exists with the given [nodeId].
1304+
void replaceNodeById(
1305+
String nodeId,
1306+
DocumentNode newNode,
1307+
) {
1308+
final index = getNodeIndexById(nodeId);
1309+
1310+
if (index >= 0) {
1311+
_nodes.removeAt(index);
1312+
_nodes.insert(index, newNode);
1313+
_refreshNodeIdCaches();
1314+
} else {
1315+
throw Exception('Could not find node with ID: $nodeId');
1316+
}
1317+
}
1318+
1319+
/// Returns [true] if the content of the [other] [Document] is equivalent
12881320
/// to the content of this [Document].
12891321
///
12901322
/// Content equivalency compares types of content nodes, and the content
@@ -1348,7 +1380,7 @@ class MutableDocument with Iterable<DocumentNode> implements Document, Editable
13481380
void reset() {
13491381
_nodes
13501382
..clear()
1351-
..addAll(_latestNodesSnapshot.map((node) => node.copy()).toList());
1383+
..addAll(_latestNodesSnapshot);
13521384
_refreshNodeIdCaches();
13531385

13541386
_didReset = true;

super_editor/lib/src/default_editor/blockquote.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,10 @@ class SplitBlockquoteCommand extends EditCommand {
409409
final endText = splitPosition.offset < text.length ? text.copyText(splitPosition.offset) : AttributedText();
410410

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

416417
// Create a new node that will follow the current node. Set its text
417418
// to the text that was removed from the current node.
@@ -424,7 +425,7 @@ class SplitBlockquoteCommand extends EditCommand {
424425

425426
// Insert the new node after the current node.
426427
document.insertNodeAfter(
427-
existingNode: node,
428+
existingNodeId: node.id,
428429
newNode: newNode,
429430
);
430431

super_editor/lib/src/default_editor/box_component.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import '../core/document_layout.dart';
1515
final _log = Logger(scope: 'box_component.dart');
1616

1717
/// Base implementation for a [DocumentNode] that only supports [UpstreamDownstreamNodeSelection]s.
18+
@immutable
1819
abstract class BlockNode extends DocumentNode {
1920
BlockNode({
2021
Map<String, dynamic>? metadata,

0 commit comments

Comments
 (0)