feat: double hyphen to em dash (#395)
* feat: double hyphen to em dash * chore: fix tests --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
parent
1f2668c18a
commit
2eb801e7f5
7 changed files with 160 additions and 6 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
/// insert divider into a document by typing three minuses(-).
|
/// insert divider into a document by typing three dashes (-)
|
||||||
|
/// or one em dash (—) and one dash (-)
|
||||||
///
|
///
|
||||||
/// - support
|
/// - support
|
||||||
/// - desktop
|
/// - desktop
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ export 'format_single_character/format_strikethrough.dart';
|
||||||
export 'format_double_characters/format_double_characters.dart';
|
export 'format_double_characters/format_double_characters.dart';
|
||||||
export 'format_double_characters/format_bold.dart';
|
export 'format_double_characters/format_bold.dart';
|
||||||
export 'format_double_characters/format_strikethrough.dart';
|
export 'format_double_characters/format_strikethrough.dart';
|
||||||
|
export 'format_double_characters/format_double_hyphen.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
const _hyphen = '-';
|
||||||
|
const _emDash = '—'; // This is an em dash — not a single dash - !!
|
||||||
|
|
||||||
|
/// format two hyphens into an em dash
|
||||||
|
///
|
||||||
|
/// - support
|
||||||
|
/// - desktop
|
||||||
|
/// - mobile
|
||||||
|
/// - web
|
||||||
|
///
|
||||||
|
final CharacterShortcutEvent formatDoubleHyphenEmDash = CharacterShortcutEvent(
|
||||||
|
key: 'format double hyphen into an em dash',
|
||||||
|
character: _hyphen,
|
||||||
|
handler: (editorState) async => handleDoubleCharacterReplacement(
|
||||||
|
editorState: editorState,
|
||||||
|
character: _hyphen,
|
||||||
|
replacement: _emDash,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<bool> handleDoubleCharacterReplacement({
|
||||||
|
required EditorState editorState,
|
||||||
|
required String character,
|
||||||
|
required String replacement,
|
||||||
|
}) async {
|
||||||
|
assert(character.length == 1);
|
||||||
|
|
||||||
|
Selection? selection = editorState.selection;
|
||||||
|
if (selection == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
await editorState.deleteSelection(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection = editorState.selection;
|
||||||
|
if (selection == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final node = editorState.getNodeAtPath(selection.end.path);
|
||||||
|
final delta = node?.delta;
|
||||||
|
if (node == null || delta == null || delta.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.end.offset > 0) {
|
||||||
|
final plain = delta.toPlainText();
|
||||||
|
|
||||||
|
final previousCharacter = plain[selection.end.offset - 1];
|
||||||
|
if (previousCharacter != _hyphen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final replace = editorState.transaction
|
||||||
|
..replaceText(
|
||||||
|
node,
|
||||||
|
selection.end.offset - 1,
|
||||||
|
1,
|
||||||
|
_emDash,
|
||||||
|
);
|
||||||
|
|
||||||
|
await editorState.apply(replace);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ bool handleFormatByWrappingWithSingleCharacter({
|
||||||
// Determine if a 'Character' already exists in the node and only once.
|
// Determine if a 'Character' already exists in the node and only once.
|
||||||
// 1. This is no 'Character' in the plainText: indexOf returns -1.
|
// 1. This is no 'Character' in the plainText: indexOf returns -1.
|
||||||
// 2. More than one 'Character' in the plainText: the headCharIndex and endCharIndex are supposed to be the same, if not, which means plainText has more than one character. For example: when plainText is '_abc', it will trigger formatting(remind:the last char is used to trigger the formatting,so it won't be counted in the plainText.). But adding '_' after 'a__ab' won't trigger formatting.
|
// 2. More than one 'Character' in the plainText: the headCharIndex and endCharIndex are supposed to be the same, if not, which means plainText has more than one character. For example: when plainText is '_abc', it will trigger formatting(remind:the last char is used to trigger the formatting,so it won't be counted in the plainText.). But adding '_' after 'a__ab' won't trigger formatting.
|
||||||
// 3. there're two characters connecting together, like adding '_' after 'abc_' won't trigger formatting.
|
// 3. there are two characters connecting together, like adding '_' after 'abc_' won't trigger formatting.
|
||||||
if (headCharIndex == -1 ||
|
if (headCharIndex == -1 ||
|
||||||
headCharIndex != endCharIndex ||
|
headCharIndex != endCharIndex ||
|
||||||
headCharIndex == selection.end.offset - 1) {
|
headCharIndex == selection.end.offset - 1) {
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,7 @@ final List<CharacterShortcutEvent> markdownSyntaxShortcutEvents = [
|
||||||
// format bold, **bold** or __bold__
|
// format bold, **bold** or __bold__
|
||||||
formatDoubleAsterisksToBold,
|
formatDoubleAsterisksToBold,
|
||||||
formatDoubleUnderscoresToBold,
|
formatDoubleUnderscoresToBold,
|
||||||
|
|
||||||
|
// format -- into em dash
|
||||||
|
formatDoubleHyphenEmDash,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../../../infra/testable_editor.dart';
|
||||||
|
|
||||||
|
const _hyphen = '-';
|
||||||
|
const _emDash = '—'; // This is an em dash
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
group('format_double_hyphen.dart', () {
|
||||||
|
testWidgets('two dashes to em dash', (tester) async {
|
||||||
|
final editor = tester.editor..addEmptyParagraph();
|
||||||
|
await editor.startTesting();
|
||||||
|
|
||||||
|
await editor.updateSelection(Selection.collapsed(Position(path: [0])));
|
||||||
|
|
||||||
|
await editor.ime.typeText(_hyphen);
|
||||||
|
await editor.ime.typeText(_hyphen);
|
||||||
|
|
||||||
|
final delta = editor.nodeAtPath([0])!.delta!;
|
||||||
|
expect(delta.length, 1);
|
||||||
|
expect(delta.toPlainText(), _emDash);
|
||||||
|
|
||||||
|
await editor.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('two dashes to em dash with selection', (tester) async {
|
||||||
|
const welcome = 'Welcome';
|
||||||
|
const initialText = '$_hyphen$welcome';
|
||||||
|
|
||||||
|
final editor = tester.editor..addParagraph(initialText: initialText);
|
||||||
|
await editor.startTesting();
|
||||||
|
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.single(
|
||||||
|
path: [0],
|
||||||
|
startOffset: 1,
|
||||||
|
endOffset: initialText.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await editor.ime.typeText(_hyphen);
|
||||||
|
|
||||||
|
final delta = editor.nodeAtPath([0])!.delta!;
|
||||||
|
expect(delta.length, 1);
|
||||||
|
expect(delta.toPlainText(), _emDash);
|
||||||
|
|
||||||
|
await editor.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('em dash + dash to divider', (tester) async {
|
||||||
|
final editor = tester.editor..addParagraph(initialText: _emDash);
|
||||||
|
await editor.startTesting();
|
||||||
|
|
||||||
|
await editor.updateSelection(
|
||||||
|
Selection.collapsed(Position(path: [0], offset: 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await editor.ime.typeText(_hyphen);
|
||||||
|
|
||||||
|
final node = editor.nodeAtPath([0]);
|
||||||
|
expect(node!.type, DividerBlockKeys.type);
|
||||||
|
|
||||||
|
await editor.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -315,14 +315,24 @@ class MockIMEInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> replaceText(String text) async {
|
Future<void> replaceText(String text) async {
|
||||||
final selection = editorState.selection?.normalized;
|
var selection = editorState.selection?.normalized;
|
||||||
if (selection == null || selection.isCollapsed) {
|
if (selection == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final texts = editorState.getTextInSelection(selection).join('\n');
|
if (!selection.isSingle || !selection.isCollapsed) {
|
||||||
|
await editorState.deleteSelection(selection);
|
||||||
|
}
|
||||||
|
selection = editorState.selection?.normalized;
|
||||||
|
if (selection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final oldText =
|
||||||
|
editorState.getNodeAtPath(selection.start.path)!.delta!.toPlainText();
|
||||||
|
|
||||||
await imeInput.apply([
|
await imeInput.apply([
|
||||||
TextEditingDeltaReplacement(
|
TextEditingDeltaReplacement(
|
||||||
oldText: ' $texts',
|
oldText: ' $oldText',
|
||||||
replacementText: text,
|
replacementText: text,
|
||||||
replacedRange: TextSelection(
|
replacedRange: TextSelection(
|
||||||
baseOffset: selection.startIndex + 1,
|
baseOffset: selection.startIndex + 1,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue