feat: implement converting 1. to numbered list style

This commit is contained in:
Lucas.Xu 2023-04-27 15:34:39 +08:00
commit f624f3a308
11 changed files with 377 additions and 149 deletions

View file

@ -84,6 +84,9 @@ class SimpleEditor extends StatelessWidget {
formatAsteriskToBulletedList, formatAsteriskToBulletedList,
formatMinusToBulletedList, formatMinusToBulletedList,
// numbered list
formatNumberToNumberedList,
// slash // slash
slashCommand, slashCommand,

View file

@ -9,6 +9,10 @@ class Position {
this.offset = 0, this.offset = 0,
}); });
Position.invalid()
: path = [-1],
offset = -1;
factory Position.fromJson(Map<String, dynamic> json) { factory Position.fromJson(Map<String, dynamic> json) {
final path = Path.from(json['path'] as List); final path = Path.from(json['path'] as List);
final offset = json['offset']; final offset = json['offset'];

View file

@ -40,6 +40,10 @@ class Selection {
: start = position, : start = position,
end = position; end = position;
Selection.invalid()
: start = Position.invalid(),
end = Position.invalid();
final Position start; final Position start;
final Position end; final Position end;

View file

@ -0,0 +1,71 @@
import 'package:appflowy_editor/appflowy_editor.dart';
/// Formats the current node to specified markdown style.
///
/// For example,
/// bulleted list: '- '
/// numbered list: '1. '
/// quote: '> '
/// ...
Future<bool> formatMarkdownSymbol(
EditorState editorState,
bool Function(Node node) shouldFormat,
bool Function(
String text,
Selection selection,
)
predicate,
Node Function(
String text,
Node node,
Delta delta,
)
nodeBuilder,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final position = selection.end;
final node = editorState.getNodeAtPath(position.path);
if (node == null || !shouldFormat(node)) {
return false;
}
// Get the text from the start of the document until the selection.
final delta = node.delta;
if (delta == null) {
return false;
}
final text = delta.toPlainText().substring(0, selection.end.offset);
// If the text doesn't match the predicate, then we don't want to
// format it.
if (!predicate(text, selection)) {
return false;
}
final afterSelection = Selection.collapsed(
Position(
path: node.path,
offset: 0,
),
);
final formattedNode = nodeBuilder(text, node, delta);
// Create a transaction that replaces the current node with the
// formatted node.
final transaction = editorState.transaction
..insertNode(
node.path,
formattedNode,
)
..deleteNode(node)
..afterSelection = afterSelection;
await editorState.apply(transaction);
return true;
}

View file

@ -11,6 +11,7 @@ export 'bulleted_list_block_component/bulleted_list_character_shortcut.dart';
// numbered list // numbered list
export 'numbered_list_block_component/numbered_list_block_component.dart'; export 'numbered_list_block_component/numbered_list_block_component.dart';
export 'numbered_list_block_component/numbered_list_character_shortcut.dart';
// quote // quote
export 'quote_block_component/quote_block_component.dart'; export 'quote_block_component/quote_block_component.dart';

View file

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/base_component/markdown_format_helper.dart';
/// Convert '* ' to bulleted list /// Convert '* ' to bulleted list
/// ///
@ -38,45 +39,15 @@ Future<bool> _formatSymbolToBulletedList(
) async { ) async {
assert(symbol.length == 1); assert(symbol.length == 1);
final selection = editorState.selection; return formatMarkdownSymbol(
if (selection == null || !selection.isCollapsed) { editorState,
return false; (node) => node.type != 'bulleted_list',
} (text, _) => text == symbol,
(_, node, delta) => Node(
final nodes = editorState.getNodesInSelection(selection); type: 'bulleted_list',
if (nodes.length != 1 || nodes.first.type == 'bulleted_list') { attributes: {
return false; 'delta': delta.compose(Delta()..delete(symbol.length)).toJson(),
} },
final node = nodes.first;
final delta = node.delta;
if (delta == null) {
return false;
}
final text = delta.toPlainText().substring(0, selection.end.offset);
if (symbol != text) {
return false;
}
final afterSelection = Selection.collapsed(
Position(
path: node.path,
offset: 0,
), ),
); );
final bulletedListNode = Node(
type: 'bulleted_list',
attributes: {
'delta': delta.compose(Delta()..delete(symbol.length)).toJson(),
},
);
final transaction = editorState.transaction
..insertNode(
node.path,
bulletedListNode,
)
..deleteNode(node)
..afterSelection = afterSelection;
await editorState.apply(transaction);
return true;
} }

View file

@ -0,0 +1,39 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/base_component/markdown_format_helper.dart';
final _numberRegex = RegExp(r'^(\d+)\.');
/// Convert 'num. ' to bulleted list
///
/// - support
/// - desktop
/// - mobile
/// - web
///
CharacterShortcutEvent formatNumberToNumberedList = CharacterShortcutEvent(
key: 'format number to numbered list',
character: ' ',
handler: (editorState) async => await formatMarkdownSymbol(
editorState,
(node) => node.type != 'numbered_list',
(text, selection) {
final match = _numberRegex.firstMatch(text);
final matchText = match?.group(0);
final numberText = match?.group(1);
return match != null &&
matchText != null &&
numberText != null &&
selection.endIndex == matchText.length;
},
(text, node, delta) {
final match = _numberRegex.firstMatch(text)!;
final matchText = match.group(0)!;
return Node(
type: 'numbered_list',
attributes: {
'delta': delta.compose(Delta()..delete(matchText.length)).toJson(),
},
);
},
),
);

View file

@ -28,6 +28,7 @@ dependencies:
markdown: ^7.0.1 markdown: ^7.0.1
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
tuple: ^2.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -0,0 +1,136 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../util/util.dart';
void main() async {
setUpAll(() {
if (kDebugMode) {
activateLog();
}
});
tearDownAll(() {
if (kDebugMode) {
deactivateLog();
}
});
group('formatNumberToNumberedList', () {
// Before
// 1|Welcome to AppFlowy Editor 🔥!
// After
// 1|Welcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` after the number but not dot', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('1$text'),
);
final editorState = EditorState(document: document);
// 1|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 1),
);
editorState.selection = selection;
final before = editorState.getNodesInSelection(selection).first;
final result = await formatNumberToNumberedList.execute(editorState);
final after = editorState.getNodesInSelection(selection).first;
// nothing happens
expect(result, false);
expect(before.toJson(), after.toJson());
});
// Before
// 1.|Welcome to AppFlowy Editor 🔥!
// After
// [numbered_list]Welcome to AppFlowy Editor 🔥!
test(
'mock inputting a ` ` after the number which is located at the front of the text',
() async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('1.$text'),
);
final editorState = EditorState(document: document);
// 1.|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 2),
);
editorState.selection = selection;
final result = await formatNumberToNumberedList.execute(editorState);
expect(result, true);
final after = editorState.getNodeAtPath([0])!;
expect(after.delta!.toPlainText(), text);
expect(after.type, 'numbered_list');
});
// Before
// 1.W|elcome to AppFlowy Editor 🔥!
// After
// 1.W|elcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` in the middle of the node', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('1.$text'),
);
final editorState = EditorState(document: document);
// 1.W|elcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 3),
);
editorState.selection = selection;
final before = editorState.getNodesInSelection(selection).first;
final result = await formatNumberToNumberedList.execute(editorState);
final after = editorState.getNodesInSelection(selection).first;
// nothing happens
expect(result, false);
expect(before.toJson(), after.toJson());
});
// Before
// Welcome to AppFlowy Editor 🔥!
// 1.|Welcome to AppFlowy Editor 🔥!
// After
// Welcome to AppFlowy Editor 🔥!
//[numbered_list] Welcome to AppFlowy Editor 🔥!
test(
'mock inputting a ` ` in the middle of the node, and there\'s a other node at the front of it.',
() async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank()
.addParagraphs(
1,
builder: (index) => Delta()..insert(text),
)
.addParagraphs(
1,
builder: (index) => Delta()..insert('1.$text'),
);
final editorState = EditorState(document: document);
// Welcome to AppFlowy Editor 🔥!
// *|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [1], offset: 2),
);
editorState.selection = selection;
final result = await formatNumberToNumberedList.execute(editorState);
final after = editorState.getNodeAtPath([1])!;
// the second line will be formatted as the bulleted list style
expect(result, true);
expect(after.type, 'numbered_list');
expect(after.delta!.toPlainText(), text);
});
});
}

View file

@ -1,110 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../util/util.dart';
void main() async {
group('bulleted_list_shortcut.dart', () {
setUpAll(() {
if (kDebugMode) {
activateLog();
}
});
tearDownAll(() {
if (kDebugMode) {
deactivateLog();
}
});
group('formatAsteriskToBulletedList', () {
// Before
// *|Welcome to AppFlowy Editor 🔥!
// After
// [bulleted_list]Welcome to AppFlowy Editor 🔥!
test(
'mock inputting a ` ` after asterisk which is located at the front of the text',
() async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// *|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 1),
);
editorState.selection = selection;
final result = await formatAsteriskToBulletedList.execute(editorState);
expect(result, true);
final after = editorState.getNodeAtPath([0])!;
expect(after.delta!.toPlainText(), text);
expect(after.type, 'bulleted_list');
});
// Before
// *W|elcome to AppFlowy Editor 🔥!
// After
// *W|elcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` in the middle of the text', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// *W|elcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 2),
);
editorState.selection = selection;
final before = editorState.getNodesInSelection(selection).first;
final result = await formatAsteriskToBulletedList.execute(editorState);
final after = editorState.getNodesInSelection(selection).first;
// nothing happens
expect(result, false);
expect(before.toJson(), after.toJson());
});
// Before
// Welcome to AppFlowy Editor 🔥!
// *|Welcome to AppFlowy Editor 🔥!
// After
// Welcome to AppFlowy Editor 🔥!
//[bulleted_list] Welcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` in the middle of the text', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank()
.addParagraphs(
1,
builder: (index) => Delta()..insert(text),
)
.addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// Welcome to AppFlowy Editor 🔥!
// *|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [1], offset: 1),
);
editorState.selection = selection;
final result = await formatAsteriskToBulletedList.execute(editorState);
final after = editorState.getNodeAtPath([1])!;
// the second line will be formatted as the bulleted list style
expect(result, true);
expect(after.type, 'bulleted_list');
expect(after.delta!.toPlainText(), text);
});
});
});
}

View file

@ -0,0 +1,108 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../util/util.dart';
void main() async {
setUpAll(() {
if (kDebugMode) {
activateLog();
}
});
tearDownAll(() {
if (kDebugMode) {
deactivateLog();
}
});
group('formatAsteriskToBulletedList', () {
// Before
// *|Welcome to AppFlowy Editor 🔥!
// After
// [bulleted_list]Welcome to AppFlowy Editor 🔥!
test(
'mock inputting a ` ` after asterisk which is located at the front of the text',
() async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// *|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 1),
);
editorState.selection = selection;
final result = await formatAsteriskToBulletedList.execute(editorState);
expect(result, true);
final after = editorState.getNodeAtPath([0])!;
expect(after.delta!.toPlainText(), text);
expect(after.type, 'bulleted_list');
});
// Before
// *W|elcome to AppFlowy Editor 🔥!
// After
// *W|elcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` in the middle of the text', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank().addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// *W|elcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [0], offset: 2),
);
editorState.selection = selection;
final before = editorState.getNodesInSelection(selection).first;
final result = await formatAsteriskToBulletedList.execute(editorState);
final after = editorState.getNodesInSelection(selection).first;
// nothing happens
expect(result, false);
expect(before.toJson(), after.toJson());
});
// Before
// Welcome to AppFlowy Editor 🔥!
// *|Welcome to AppFlowy Editor 🔥!
// After
// Welcome to AppFlowy Editor 🔥!
//[bulleted_list] Welcome to AppFlowy Editor 🔥!
test('mock inputting a ` ` in the middle of the text', () async {
const text = 'Welcome to AppFlowy Editor 🔥!';
final document = Document.blank()
.addParagraphs(
1,
builder: (index) => Delta()..insert(text),
)
.addParagraphs(
1,
builder: (index) => Delta()..insert('*$text'),
);
final editorState = EditorState(document: document);
// Welcome to AppFlowy Editor 🔥!
// *|Welcome to AppFlowy Editor 🔥!
final selection = Selection.collapsed(
Position(path: [1], offset: 1),
);
editorState.selection = selection;
final result = await formatAsteriskToBulletedList.execute(editorState);
final after = editorState.getNodeAtPath([1])!;
// the second line will be formatted as the bulleted list style
expect(result, true);
expect(after.type, 'bulleted_list');
expect(after.delta!.toPlainText(), text);
});
});
}