Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion lib/src/core/document/diff.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ List<Operation> diffDocuments(Document oldDocument, Document newDocument) {
List<Operation> diffNodes(Node oldNode, Node newNode) {
final List<Operation> operations = [];

if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
if (oldNode.type != newNode.type && oldNode.path.isNotEmpty) {
operations.add(
UpdateNodeTypeOperation(
oldNode.path,
oldNode.id,
newNode.type,
oldNode.type,
newNode.attributes,
oldNode.attributes,
),
);
} else if (!_equality.equals(oldNode.attributes, newNode.attributes)) {
operations.add(
UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes),
);
Expand Down
29 changes: 29 additions & 0 deletions lib/src/core/document/document.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,35 @@ class Document {
return true;
}

/// Updates the [Node] type at the given [Path] while preserving its id,
/// children, external values, and temporary metadata.
bool updateNodeType(Path path, String type, Attributes attributes) {
if (path.isEmpty) {
return false;
}

final target = nodeAtPath(path);
final parent = target?.parent;
if (target == null || parent == null) {
return false;
}

final index = path.last;
final replacement = Node(
type: type,
id: target.id,
attributes: {...attributes},
children: target.children.toList(growable: false),
)
..externalValues = target.externalValues
..extraInfos = target.extraInfos;

target.unlink();
parent.insert(replacement, index: index);

return true;
}

/// Updates the [Node] with [Delta] at the given [Path]
bool updateText(Path path, Delta delta) {
if (path.isEmpty) {
Expand Down
18 changes: 15 additions & 3 deletions lib/src/core/document/node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,19 +263,24 @@ final class Node extends ChangeNotifier with LinkedListEntry<Node> {
/// Be careful of the children, they will be deep copied if not provided.
Node copyWith({
String? type,
String? id,
bool preserveChildIds = false,
Iterable<Node>? children,
Attributes? attributes,
}) {
final node = Node(
type: type ?? this.type,
id: nanoid(6),
id: id ?? nanoid(6),
attributes: attributes ?? {...this.attributes},
children: children ?? [],
);
if (children == null && _children.isNotEmpty) {
for (final child in _children) {
node._children.add(
child.copyWith()..parent = node,
child.copyWith(
id: preserveChildIds ? child.id : null,
preserveChildIds: preserveChildIds,
)..parent = node,
);
}
}
Expand Down Expand Up @@ -372,16 +377,23 @@ final class TextNode extends Node {
Attributes? attributes,
Delta? delta,
String? id,
bool preserveChildIds = false,
}) {
final textNode = TextNode(
children: children ?? [],
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
if (id != null) {
textNode.id = id;
}
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
textNode._children.add(
child.copyWith()..parent = textNode,
child.copyWith(
id: preserveChildIds ? child.id : null,
preserveChildIds: preserveChildIds,
)..parent = textNode,
);
}
}
Expand Down
95 changes: 95 additions & 0 deletions lib/src/core/transform/operation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,101 @@ class UpdateOperation extends Operation {
path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode;
}

/// [UpdateNodeTypeOperation] changes a node's block type without replacing
/// the node in the document tree.
class UpdateNodeTypeOperation extends Operation {
const UpdateNodeTypeOperation(
super.path,
this.nodeId,
this.type,
this.oldType,
this.attributes,
this.oldAttributes,
);

factory UpdateNodeTypeOperation.fromJson(Map<String, dynamic> json) {
final path = json['path'] as Path;
final nodeId = json['nodeId'] as String? ?? '';
final type = json['type'] as String;
final oldType = json['oldType'] as String;
final attributes = json['attributes'] as Attributes;
final oldAttributes = json['oldAttributes'] as Attributes;

return UpdateNodeTypeOperation(
path,
nodeId,
type,
oldType,
attributes,
oldAttributes,
);
}

final String nodeId;
final String type;
final String oldType;
final Attributes attributes;
final Attributes oldAttributes;

@override
Operation invert() => UpdateNodeTypeOperation(
path,
nodeId,
oldType,
type,
oldAttributes,
attributes,
);

@override
Map<String, dynamic> toJson() {
return {
'op': 'update_node_type',
'path': path,
'nodeId': nodeId,
'type': type,
'oldType': oldType,
'attributes': {...attributes},
'oldAttributes': {...oldAttributes},
};
}

@override
Operation copyWith({Path? path}) {
return UpdateNodeTypeOperation(
path ?? this.path,
nodeId,
type,
oldType,
{...attributes},
{...oldAttributes},
);
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

return other is UpdateNodeTypeOperation &&
other.path.equals(path) &&
other.nodeId == nodeId &&
other.type == type &&
other.oldType == oldType &&
mapEquals(other.attributes, attributes) &&
mapEquals(other.oldAttributes, oldAttributes);
}

@override
int get hashCode => Object.hash(
Object.hashAll(path),
nodeId,
type,
oldType,
hashAttributes(attributes),
hashAttributes(oldAttributes),
);
}

/// [UpdateTextOperation] represents a text update operation.
class UpdateTextOperation extends Operation {
const UpdateTextOperation(
Expand Down
22 changes: 22 additions & 0 deletions lib/src/core/transform/transaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ class Transaction {
);
}

/// Updates a node's type while keeping the same node id and children.
///
/// Unlike [updateNode], this replaces the node attributes. Block types have
/// different data contracts, so carrying old type-specific attributes into
/// the new block type would leak stale state.
void updateNodeType(
Node node,
String type,
Attributes attributes,
) {
add(
UpdateNodeTypeOperation(
node.path,
node.id,
type,
node.type,
{...attributes},
{...node.attributes},
),
);
}

/// Deletes the [Node] in the document.
void deleteNode(Node node) {
deleteNodesAtPath(node.path);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';

const _nodeEquality = DeepCollectionEquality();

/// Formats the current node to specified markdown style.
///
Expand Down Expand Up @@ -60,16 +63,52 @@ Future<bool> formatMarkdownSymbol(

final formattedNodes = nodesBuilder(text, node, delta);

// Create a transaction that replaces the current node with the
// formatted node.
final transaction = editorState.transaction
..insertNodes(
node.path,
formattedNodes,
)
..deleteNode(node)
..afterSelection = afterSelection;
final transaction = editorState.transaction;
if (_canUpdateNodeTypeInPlace(node, formattedNodes)) {
final formattedNode = formattedNodes.single;
transaction.updateNodeType(
node,
formattedNode.type,
formattedNode.attributes,
);
} else {
// Create a transaction that replaces the current node with the
// formatted node.
transaction
..insertNodes(
node.path,
formattedNodes,
)
..deleteNode(node);
}
transaction.afterSelection = afterSelection;

await editorState.apply(transaction);
return true;
}

bool _canUpdateNodeTypeInPlace(Node node, List<Node> formattedNodes) {
if (formattedNodes.length != 1) {
return false;
}

return _hasSameDescendantContent(node, formattedNodes.single);
}

bool _hasSameDescendantContent(Node before, Node after) {
if (before.children.length != after.children.length) {
return false;
}

for (var i = 0; i < before.children.length; i++) {
final beforeChild = before.children.elementAt(i);
final afterChild = after.children.elementAt(i);
if (beforeChild.type != afterChild.type ||
!_nodeEquality.equals(beforeChild.attributes, afterChild.attributes) ||
!_hasSameDescendantContent(beforeChild, afterChild)) {
return false;
}
}

return true;
}
32 changes: 32 additions & 0 deletions lib/src/editor_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,8 @@ class EditorState {
if (!mapEquals(op.attributes, op.oldAttributes)) {
document.update(op.path, op.attributes);
}
} else if (op is UpdateNodeTypeOperation) {
_applyUpdateNodeTypeOperation(op);
} else if (op is DeleteOperation) {
document.delete(op.path, op.nodes.length);
} else if (op is UpdateTextOperation) {
Expand Down Expand Up @@ -768,6 +770,8 @@ class EditorState {
);
}
}
} else if (op is UpdateNodeTypeOperation) {
_applyUpdateNodeTypeOperation(op);
} else if (op is UpdateOperation) {
document.update(op.path, op.attributes);
} else if (op is DeleteOperation) {
Expand All @@ -791,4 +795,32 @@ class EditorState {

return selection;
}

bool _applyUpdateNodeTypeOperation(UpdateNodeTypeOperation op) {
final node = _resolveUpdateNodeTypeTarget(op);
if (node == null) {
return false;
}

return document.updateNodeType(node.path, op.type, op.attributes);
}

Node? _resolveUpdateNodeTypeTarget(UpdateNodeTypeOperation op) {
final pathNode = document.nodeAtPath(op.path);
if (op.nodeId.isEmpty || pathNode?.id == op.nodeId) {
return pathNode;
}

final iterator = NodeIterator(
document: document,
startNode: document.root,
);
while (iterator.moveNext()) {
if (iterator.current.id == op.nodeId) {
return iterator.current;
}
}

return null;
}
}
33 changes: 33 additions & 0 deletions test/core/document/diff_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ void main() async {
);
});

test('same id type changes', () async {
final id = nanoid(6);
final documentA = Document.blank()
..insert([0], [buildNodeWithId(id, 'Hello World')]);
final documentB = Document.blank()
..insert([
0,
], [
Node(
type: HeadingBlockKeys.type,
id: id,
attributes: {
HeadingBlockKeys.level: 1,
HeadingBlockKeys.delta: (Delta()..insert('Hello World')).toJson(),
},
),
]);

final ops = diffDocuments(documentA, documentB);
expect(ops.length, 1);
final op = ops.first;
expect(op, isA<UpdateNodeTypeOperation>());
expect((op as UpdateNodeTypeOperation).nodeId, id);
expect(op.path, [0]);
expect(op.type, HeadingBlockKeys.type);

final expectation = jsonEncode(documentB.toJson());
expect(
jsonEncode((await apply(documentA, ops)).toJson()),
expectation,
);
});

test('insert', () async {
final id1 = nanoid(6);
final id2 = nanoid(6);
Expand Down
Loading
Loading