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:
parent
0a84917e71
commit
5e5b908e78
13 changed files with 162 additions and 52 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue