Merge pull request #825 from AppFlowy-IO/fix/undo-attributes

Fix: undo attributes
This commit is contained in:
Vincent Chan 2022-08-11 20:58:37 +08:00 committed by GitHub
commit e58017225d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 46 additions and 39 deletions

View File

@ -22,11 +22,15 @@ Attributes invertAttributes(Attributes? attr, Attributes? base) {
}); });
} }
Attributes? composeAttributes(Attributes? a, Attributes? b) { Attributes? composeAttributes(Attributes? a, Attributes? b,
[bool keepNull = false]) {
a ??= {}; a ??= {};
b ??= {}; b ??= {};
final Attributes attributes = {}; Attributes attributes = {...b};
attributes.addAll(Map.from(b)..removeWhere((_, value) => value == null));
if (!keepNull) {
attributes = Map.from(attributes)..removeWhere((_, value) => value == null);
}
for (final entry in a.entries) { for (final entry in a.entries) {
if (!b.containsKey(entry.key)) { if (!b.containsKey(entry.key)) {
@ -34,9 +38,5 @@ Attributes? composeAttributes(Attributes? a, Attributes? b) {
} }
} }
if (attributes.isEmpty) { return attributes.isNotEmpty ? attributes : null;
return null;
}
return attributes;
} }

View File

@ -8,7 +8,7 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
Node? parent; Node? parent;
final String type; final String type;
final LinkedList<Node> children; final LinkedList<Node> children;
final Attributes attributes; Attributes _attributes;
GlobalKey? key; GlobalKey? key;
// TODO: abstract a selectable node?? // TODO: abstract a selectable node??
@ -16,22 +16,24 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
String? get subtype { String? get subtype {
// TODO: make 'subtype' as a const value. // TODO: make 'subtype' as a const value.
if (attributes.containsKey('subtype')) { if (_attributes.containsKey('subtype')) {
assert(attributes['subtype'] is String?, assert(_attributes['subtype'] is String?,
'subtype must be a [String] or [null]'); 'subtype must be a [String] or [null]');
return attributes['subtype'] as String?; return _attributes['subtype'] as String?;
} }
return null; return null;
} }
Path get path => _path(); Path get path => _path();
Attributes get attributes => _attributes;
Node({ Node({
required this.type, required this.type,
required this.children, required this.children,
required this.attributes, required Attributes attributes,
this.parent, this.parent,
}) { }) : _attributes = attributes {
for (final child in children) { for (final child in children) {
child.parent = this; child.parent = this;
} }
@ -84,16 +86,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
} }
void updateAttributes(Attributes attributes) { void updateAttributes(Attributes attributes) {
bool shouldNotifyParent = bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype'];
this.attributes['subtype'] != attributes['subtype'];
for (final attribute in attributes.entries) { _attributes = composeAttributes(_attributes, attributes) ?? {};
if (attribute.value == null) {
this.attributes.remove(attribute.key);
} else {
this.attributes[attribute.key] = attribute.value;
}
}
// Notify the new attributes // Notify the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node // if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node. // else, just notify current node.
@ -149,8 +144,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
if (children.isNotEmpty) { if (children.isNotEmpty) {
map['children'] = children.map((node) => node.toJson()); map['children'] = children.map((node) => node.toJson());
} }
if (attributes.isNotEmpty) { if (_attributes.isNotEmpty) {
map['attributes'] = attributes; map['attributes'] = _attributes;
} }
return map; return map;
} }
@ -214,7 +209,7 @@ class TextNode extends Node {
TextNode( TextNode(
type: type ?? this.type, type: type ?? this.type,
children: children ?? this.children, children: children ?? this.children,
attributes: attributes ?? this.attributes, attributes: attributes ?? _attributes,
delta: delta ?? this.delta, delta: delta ?? this.delta,
); );

View File

@ -65,16 +65,15 @@ class StateTree {
} }
} }
Attributes? update(Path path, Attributes attributes) { bool update(Path path, Attributes attributes) {
if (path.isEmpty) { if (path.isEmpty) {
return null; return false;
} }
final updatedNode = root.childAtPath(path); final updatedNode = root.childAtPath(path);
if (updatedNode == null) { if (updatedNode == null) {
return null; return false;
} }
final previousAttributes = Attributes.from(updatedNode.attributes);
updatedNode.updateAttributes(attributes); updatedNode.updateAttributes(attributes);
return previousAttributes; return true;
} }
} }

View File

@ -383,8 +383,8 @@ class Delta extends Iterable<TextOperation> {
final length = min(thisIter.peekLength(), otherIter.peekLength()); final length = min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length); final thisOp = thisIter.next(length);
final otherOp = otherIter.next(length); final otherOp = otherIter.next(length);
final attributes = final attributes = composeAttributes(
composeAttributes(thisOp.attributes, otherOp.attributes); thisOp.attributes, otherOp.attributes, thisOp is TextRetain);
if (otherOp is TextRetain && otherOp.length > 0) { if (otherOp is TextRetain && otherOp.length > 0) {
TextOperation? newOp; TextOperation? newOp;
if (thisOp is TextRetain) { if (thisOp is TextRetain) {

View File

@ -40,10 +40,12 @@ class TransactionBuilder {
updateNode(Node node, Attributes attributes) { updateNode(Node node, Attributes attributes) {
beforeSelection = state.cursorSelection; beforeSelection = state.cursorSelection;
final inverted = invertAttributes(attributes, node.attributes);
add(UpdateOperation( add(UpdateOperation(
node.path, node.path,
Attributes.from(node.attributes)..addAll(attributes), {...attributes},
node.attributes, inverted,
)); ));
} }

View File

@ -1,3 +1,4 @@
import 'package:flowy_editor/src/document/attributes.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flowy_editor/src/document/text_delta.dart'; import 'package:flowy_editor/src/document/text_delta.dart';
@ -240,7 +241,8 @@ void main() {
..retain(3, {'bold': null}); ..retain(3, {'bold': null});
final inverted = delta.invert(base); final inverted = delta.invert(base);
expect(expected, inverted); expect(expected, inverted);
expect(base.compose(delta).compose(inverted), base); final t = base.compose(delta).compose(inverted);
expect(t, base);
}); });
}); });
group('json', () { group('json', () {
@ -314,4 +316,14 @@ void main() {
expect(delta.prevRunePosition(0), -1); expect(delta.prevRunePosition(0), -1);
}); });
}); });
group("attributes", () {
test("compose", () {
final attrs = composeAttributes({"a": null}, {"b": null}, true);
expect(attrs != null, true);
expect(attrs!.containsKey("a"), true);
expect(attrs.containsKey("b"), true);
expect(attrs["a"], null);
expect(attrs["b"], null);
});
});
} }

View File

@ -68,9 +68,8 @@ void main() {
final String response = await rootBundle.loadString('assets/document.json'); final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response)); final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data); final stateTree = StateTree.fromJson(data);
final attributes = stateTree.update([1, 1], {'text-type': 'heading1'}); final test = stateTree.update([1, 1], {'text-type': 'heading1'});
expect(attributes != null, true); expect(test, true);
expect(attributes!['text-type'], 'checkbox');
final updatedNode = stateTree.nodeAtPath([1, 1]); final updatedNode = stateTree.nodeAtPath([1, 1]);
expect(updatedNode != null, true); expect(updatedNode != null, true);
expect(updatedNode!.attributes['text-type'], 'heading1'); expect(updatedNode!.attributes['text-type'], 'heading1');