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. '
|
/// 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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [],
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue