fix: resolve data loss issue during block type conversion (#726)

* fix: resolve data loss issue during block type conversion

* fix: flutter test

* test: add more tests
This commit is contained in:
Lucas.Xu 2024-02-26 08:44:43 +07:00 • committed by GitHub
commit 5e5b908e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 162 additions and 52 deletions

View file

@ -7,6 +7,12 @@ import 'package:appflowy_editor/appflowy_editor.dart';
/// numbered list: '1. '
/// quote: '" '
/// ...
///
/// The [nodeBuilder] can return a list of nodes, which will be inserted
/// into the document.
/// For example, when converting a bulleted list to a heading and the heading is
/// not allowed to contain children, then the [nodeBuilder] should return a list
/// of nodes, which contains the heading node and the children nodes.
Future<bool> formatMarkdownSymbol(
EditorState editorState,
bool Function(Node node) shouldFormat,
@ -15,11 +21,11 @@ Future<bool> formatMarkdownSymbol(
String text,
Selection selection,
) predicate,
Node Function(
List<Node> Function(
String text,
Node node,
Delta delta,
) nodeBuilder,
) nodesBuilder,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
@ -53,14 +59,14 @@ Future<bool> formatMarkdownSymbol(
),
);
final formattedNode = nodeBuilder(text, node, delta);
final formattedNodes = nodesBuilder(text, node, delta);
// Create a transaction that replaces the current node with the
// formatted node.
final transaction = editorState.transaction
..insertNode(
..insertNodes(
node.path,
formattedNode,
formattedNodes,
)
..deleteNode(node)
..afterSelection = afterSelection;

View file

@ -56,13 +56,16 @@ Future<bool> _formatSymbolToBulletedList(
return formatMarkdownSymbol(
editorState,
(node) => node.type != 'bulleted_list',
(node) => node.type != BulletedListBlockKeys.type,
(_, text, __) => text == symbol,
(_, node, delta) => Node(
type: 'bulleted_list',
attributes: {
'delta': delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
(_, node, delta) => [
node.copyWith(
type: BulletedListBlockKeys.type,
attributes: {
BulletedListBlockKeys.delta:
delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
],
);
}

View file

@ -23,10 +23,13 @@ CharacterShortcutEvent formatSignToHeading = CharacterShortcutEvent(
},
(text, node, delta) {
final numberOfSign = text.split('').length;
return headingNode(
level: numberOfSign,
delta: delta.compose(Delta()..delete(numberOfSign)),
);
return [
headingNode(
level: numberOfSign,
delta: delta.compose(Delta()..delete(numberOfSign)),
),
if (node.children.isNotEmpty) ...node.children,
];
},
),
);

View file

@ -14,7 +14,7 @@ CharacterShortcutEvent formatNumberToNumberedList = CharacterShortcutEvent(
character: ' ',
handler: (editorState) async => await formatMarkdownSymbol(
editorState,
(node) => node.type != 'numbered_list',
(node) => node.type != NumberedListBlockKeys.type,
(node, text, selection) {
final match = _numberRegex.firstMatch(text);
if (match == null) return false;
@ -46,13 +46,16 @@ CharacterShortcutEvent formatNumberToNumberedList = CharacterShortcutEvent(
final match = _numberRegex.firstMatch(text)!;
final matchText = match.group(0)!;
final number = matchText.substring(0, matchText.length - 1);
return Node(
type: 'numbered_list',
attributes: {
'delta': delta.compose(Delta()..delete(matchText.length)).toJson(),
'number': int.tryParse(number),
},
);
return [
node.copyWith(
type: NumberedListBlockKeys.type,
attributes: {
NumberedListBlockKeys.delta:
delta.compose(Delta()..delete(matchText.length)).toJson(),
NumberedListBlockKeys.number: int.tryParse(number),
},
),
];
},
),
);
@ -69,6 +72,6 @@ CharacterShortcutEvent insertNewLineAfterNumberedList = CharacterShortcutEvent(
character: '\n',
handler: (editorState) async => await insertNewLineInType(
editorState,
'numbered_list',
NumberedListBlockKeys.type,
),
);

View file

@ -16,11 +16,13 @@ CharacterShortcutEvent formatDoubleQuoteToQuote = CharacterShortcutEvent(
editorState,
(node) => node.type != QuoteBlockKeys.type,
(_, text, __) => _doubleQuotes.any((element) => element == text),
(_, node, delta) => Node(
type: 'quote',
attributes: {
'delta': delta.compose(Delta()..delete(1)).toJson(),
},
),
(_, node, delta) => [
quoteNode(
attributes: {
QuoteBlockKeys.delta: delta.compose(Delta()..delete(1)).toJson(),
},
),
if (node.children.isNotEmpty) ...node.children,
],
),
);

View file

@ -23,17 +23,19 @@ class TodoListBlockKeys {
Node todoListNode({
required bool checked,
String? text,
Delta? delta,
String? textDirection,
Attributes? attributes,
Iterable<Node>? children,
}) {
attributes ??= {'delta': (delta ?? Delta()).toJson()};
return Node(
type: TodoListBlockKeys.type,
attributes: {
...attributes,
TodoListBlockKeys.checked: checked,
TodoListBlockKeys.delta:
(delta ?? (Delta()..insert(text ?? ''))).toJson(),
if (attributes != null) ...attributes,
if (textDirection != null) TodoListBlockKeys.textDirection: textDirection,
},
children: children ?? [],

View file

@ -105,13 +105,16 @@ Future<bool> _formatSymbolToUncheckedBox({
editorState,
(node) => node.type != 'todo_list',
(_, text, __) => text == symbol,
(_, node, delta) => Node(
type: 'todo_list',
attributes: {
'checked': false,
'delta': delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
(_, node, delta) => [
node.copyWith(
type: TodoListBlockKeys.type,
attributes: {
TodoListBlockKeys.checked: false,
TodoListBlockKeys.delta:
delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
],
);
}
@ -125,12 +128,15 @@ Future<bool> _formatSymbolToCheckedBox({
editorState,
(node) => node.type != 'todo_list',
(_, text, __) => text == symbol,
(_, node, delta) => Node(
type: 'todo_list',
attributes: {
'checked': true,
'delta': delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
(_, node, delta) => [
node.copyWith(
type: TodoListBlockKeys.type,
attributes: {
TodoListBlockKeys.checked: true,
TodoListBlockKeys.delta:
delta.compose(Delta()..delete(symbol.length)).toJson(),
},
),
],
);
}

View file

@ -21,7 +21,7 @@ void main() async {
(result, before, after, editorState) {
expect(result, true);
expect(after.delta!.toPlainText(), text);
expect(after.type, 'bulleted_list');
expect(after.type, BulletedListBlockKeys.type);
},
text: text,
);

View file

@ -116,5 +116,32 @@ void main() async {
expect(after.type, 'heading');
expect(after.delta!.toPlainText(), text);
});
test('convert bulleted_list to heading', () async {
const syntax = '#';
const text = 'Welcome to AppFlowy Editor 🔥!';
testFormatCharacterShortcut(
formatSignToHeading,
syntax,
syntax.length,
(result, before, after, editorState) {
expect(result, true);
expect(after.delta!.toPlainText(), text);
expect(after.type, HeadingBlockKeys.type);
expect(after.attributes[HeadingBlockKeys.level], 1);
expect(after.children.isEmpty, true);
expect(after.next!.delta!.toPlainText(), '1 $text');
expect(after.next!.next!.delta!.toPlainText(), '2 $text');
},
text: text,
node: bulletedListNode(
text: '$syntax$text',
children: [
bulletedListNode(text: '1 $text'),
bulletedListNode(text: '2 $text'),
],
),
);
});
});
}

View file

@ -172,5 +172,31 @@ void main() async {
expect(after.type, ParagraphBlockKeys.type);
expect(after.delta!.toPlainText(), '3.');
});
test('convert todo_list to bulleted_list', () async {
const syntax = '1.';
const text = 'Welcome to AppFlowy Editor 🔥!';
testFormatCharacterShortcut(
formatNumberToNumberedList,
syntax,
syntax.length,
(result, before, after, editorState) {
expect(result, true);
expect(after.delta!.toPlainText(), text);
expect(after.type, NumberedListBlockKeys.type);
expect(after.children[0].delta!.toPlainText(), '1 $text');
expect(after.children[1].delta!.toPlainText(), '2 $text');
},
text: text,
node: todoListNode(
text: '$syntax$text',
checked: false,
children: [
todoListNode(text: '1 $text', checked: false),
todoListNode(text: '2 $text', checked: false),
],
),
);
});
});
}

View file

@ -10,10 +10,16 @@ Future<void> testFormatCharacterShortcut(
void Function(bool result, Node before, Node after, EditorState editorState)
test, {
String text = 'Welcome to AppFlowy Editor 🔥!',
Node? node,
}) async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()..insert('$prefix$text'),
);
final document = Document.blank();
if (node != null) {
document.insert([0], [node]);
} else {
document.addParagraph(
builder: (index) => Delta()..insert('$prefix$text'),
);
}
final editorState = EditorState(document: document);
final selection = Selection.collapsed(

View file

@ -86,6 +86,32 @@ void main() async {
text: text,
);
});
test('convert numbered_list to todo_list', () async {
const syntax = '-[x]';
const text = 'Welcome to AppFlowy Editor 🔥!';
testFormatCharacterShortcut(
formatHyphenFilledBracketsToCheckedBox,
syntax,
syntax.length,
(result, before, after, editorState) {
expect(result, true);
expect(after.delta!.toPlainText(), text);
expect(after.type, TodoListBlockKeys.type);
expect(after.attributes[TodoListBlockKeys.checked], true);
expect(after.children[0].delta!.toPlainText(), '1 $text');
expect(after.children[1].delta!.toPlainText(), '2 $text');
},
text: text,
node: bulletedListNode(
text: '$syntax$text',
children: [
bulletedListNode(text: '1 $text'),
bulletedListNode(text: '2 $text'),
],
),
);
});
},
);
}

View file

@ -8,7 +8,7 @@ void main() async {
test('built-in json', () {
final json = jsonDecode(quillDeltaSample.replaceAll('\\\\\n', '\\n'));
final document = quillDeltaEncoder.convert(Delta.fromJson(json));
expect(jsonEncode(document.toJson()), documentSample);
expect(document.toJson(), jsonDecode(documentSample));
});
test('issues 356', () {