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. ' /// numbered list: '1. '
/// quote: '" ' /// 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( Future<bool> formatMarkdownSymbol(
EditorState editorState, EditorState editorState,
bool Function(Node node) shouldFormat, bool Function(Node node) shouldFormat,
@ -15,11 +21,11 @@ Future<bool> formatMarkdownSymbol(
String text, String text,
Selection selection, Selection selection,
) predicate, ) predicate,
Node Function( List<Node> Function(
String text, String text,
Node node, Node node,
Delta delta, Delta delta,
) nodeBuilder, ) nodesBuilder,
) async { ) async {
final selection = editorState.selection; final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) { 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 // Create a transaction that replaces the current node with the
// formatted node. // formatted node.
final transaction = editorState.transaction final transaction = editorState.transaction
..insertNode( ..insertNodes(
node.path, node.path,
formattedNode, formattedNodes,
) )
..deleteNode(node) ..deleteNode(node)
..afterSelection = afterSelection; ..afterSelection = afterSelection;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,5 +116,32 @@ void main() async {
expect(after.type, 'heading'); expect(after.type, 'heading');
expect(after.delta!.toPlainText(), text); 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.type, ParagraphBlockKeys.type);
expect(after.delta!.toPlainText(), '3.'); 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) void Function(bool result, Node before, Node after, EditorState editorState)
test, { test, {
String text = 'Welcome to AppFlowy Editor 🔥!', String text = 'Welcome to AppFlowy Editor 🔥!',
Node? node,
}) async { }) async {
final document = Document.blank().addParagraph( final document = Document.blank();
builder: (index) => Delta()..insert('$prefix$text'), if (node != null) {
); document.insert([0], [node]);
} else {
document.addParagraph(
builder: (index) => Delta()..insert('$prefix$text'),
);
}
final editorState = EditorState(document: document); final editorState = EditorState(document: document);
final selection = Selection.collapsed( final selection = Selection.collapsed(

View file

@ -86,6 +86,32 @@ void main() async {
text: text, 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', () { test('built-in json', () {
final json = jsonDecode(quillDeltaSample.replaceAll('\\\\\n', '\\n')); final json = jsonDecode(quillDeltaSample.replaceAll('\\\\\n', '\\n'));
final document = quillDeltaEncoder.convert(Delta.fromJson(json)); final document = quillDeltaEncoder.convert(Delta.fromJson(json));
expect(jsonEncode(document.toJson()), documentSample); expect(document.toJson(), jsonDecode(documentSample));
}); });
test('issues 356', () { test('issues 356', () {