diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/built_in_attribute_keys.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/built_in_attribute_keys.dart index 0ed5440a9a..8cfe822f46 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/built_in_attribute_keys.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/built_in_attribute_keys.dart @@ -37,6 +37,7 @@ class BuiltInAttributeKey { static String checkbox = 'checkbox'; static String code = 'code'; static String number = 'number'; + static String defaultFormating = 'defaultFormating'; static List partialStyleKeys = [ BuiltInAttributeKey.bold, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart new file mode 100644 index 0000000000..37a45805ab --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart @@ -0,0 +1,132 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +// convert **abc** to bold abc. +ShortcutEventHandler doubleAsterisksToBold = (editorState, event) { + final selectionService = editorState.service.selectionService; + final selection = selectionService.currentSelection.value; + final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { + return KeyEventResult.ignored; + } + + final textNode = textNodes.first; + final text = textNode.toRawString().substring(0, selection.end.offset); + + // make sure the last two characters are **. + if (text.length < 2 || text[selection.end.offset - 1] != '*') { + return KeyEventResult.ignored; + } + + // find all the index of `*`. + final asteriskIndexes = []; + for (var i = 0; i < text.length; i++) { + if (text[i] == '*') { + asteriskIndexes.add(i); + } + } + + if (asteriskIndexes.length < 3) { + return KeyEventResult.ignored; + } + + // make sure the second to last and third to last asterisks are connected. + final thirdToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 3]; + final secondToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 2]; + final lastAsterisIndex = asteriskIndexes[asteriskIndexes.length - 1]; + if (secondToLastAsteriskIndex != thirdToLastAsteriskIndex + 1 || + lastAsterisIndex == secondToLastAsteriskIndex + 1) { + return KeyEventResult.ignored; + } + + // delete the last three asterisks. + // update the style of the text surround by `** **` to bold. + // and update the cursor position. + TransactionBuilder(editorState) + ..deleteText(textNode, lastAsterisIndex, 1) + ..deleteText(textNode, thirdToLastAsteriskIndex, 2) + ..formatText( + textNode, + thirdToLastAsteriskIndex, + selection.end.offset - thirdToLastAsteriskIndex - 3, + { + BuiltInAttributeKey.bold: true, + BuiltInAttributeKey.defaultFormating: true, + }, + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: selection.end.offset - 3, + ), + ) + ..commit(); + + return KeyEventResult.handled; +}; + +// convert __abc__ to bold abc. +ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) { + final selectionService = editorState.service.selectionService; + final selection = selectionService.currentSelection.value; + final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { + return KeyEventResult.ignored; + } + + final textNode = textNodes.first; + final text = textNode.toRawString().substring(0, selection.end.offset); + + // make sure the last two characters are __. + if (text.length < 2 || text[selection.end.offset - 1] != '_') { + return KeyEventResult.ignored; + } + + // find all the index of `_`. + final underscoreIndexes = []; + for (var i = 0; i < text.length; i++) { + if (text[i] == '_') { + underscoreIndexes.add(i); + } + } + + if (underscoreIndexes.length < 3) { + return KeyEventResult.ignored; + } + + // make sure the second to last and third to last underscores are connected. + final thirdToLastUnderscoreIndex = + underscoreIndexes[underscoreIndexes.length - 3]; + final secondToLastUnderscoreIndex = + underscoreIndexes[underscoreIndexes.length - 2]; + final lastAsterisIndex = underscoreIndexes[underscoreIndexes.length - 1]; + if (secondToLastUnderscoreIndex != thirdToLastUnderscoreIndex + 1 || + lastAsterisIndex == secondToLastUnderscoreIndex + 1) { + return KeyEventResult.ignored; + } + + // delete the last three underscores. + // update the style of the text surround by `__ __` to bold. + // and update the cursor position. + TransactionBuilder(editorState) + ..deleteText(textNode, lastAsterisIndex, 1) + ..deleteText(textNode, thirdToLastUnderscoreIndex, 2) + ..formatText( + textNode, + thirdToLastUnderscoreIndex, + selection.end.offset - thirdToLastUnderscoreIndex - 3, + { + BuiltInAttributeKey.bold: true, + BuiltInAttributeKey.defaultFormating: true, + }, + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: selection.end.offset - 3, + ), + ) + ..commit(); + + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart index 21e3e90b0d..7cfb7288ba 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart @@ -4,6 +4,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_ke import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; @@ -252,6 +253,16 @@ List builtInShortcutEvents = [ command: 'tab', handler: tabHandler, ), + ShortcutEvent( + key: 'Double stars to bold', + command: 'shift+asterisk', + handler: doubleAsterisksToBold, + ), + ShortcutEvent( + key: 'Double underscores to bold', + command: 'shift+underscore', + handler: doubleUnderscoresToBold, + ), ShortcutEvent( key: 'Backquote to code', command: 'backquote', diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart index 81fb46dff9..5102407e1b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart @@ -139,6 +139,12 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.keyZ) { return PhysicalKeyboardKey.keyZ; } + if (this == LogicalKeyboardKey.asterisk) { + return PhysicalKeyboardKey.digit8; + } + if (this == LogicalKeyboardKey.underscore) { + return PhysicalKeyboardKey.minus; + } if (this == LogicalKeyboardKey.tilde) { return PhysicalKeyboardKey.backquote; } diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart new file mode 100644 index 0000000000..d60239ae49 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart @@ -0,0 +1,277 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('markdown_syntax_to_styled_text_handler.dart', () { + group('convert double asterisks to bold', () { + Future insertAsterisk( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.asterisk, + isShiftPressed: true, + ); + } + } + + testWidgets('**AppFlowy** to bold AppFlowy', (tester) async { + const text = '**AppFlowy*'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertAsterisk(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('App**Flowy** to bold AppFlowy', (tester) async { + const text = 'App**Flowy*'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertAsterisk(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 3, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async { + const text = '***AppFlowy*'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertAsterisk(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 1, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), '*AppFlowy'); + }); + + testWidgets('**AppFlowy** application to bold AppFlowy only', + (tester) async { + const boldText = '**AppFlowy*'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + + for (var i = 0; i < boldText.length; i++) { + await editor.insertText(textNode, boldText[i], i); + } + await insertAsterisk(editor); + final boldTextLength = boldText.replaceAll('*', '').length; + final appFlowyBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: boldTextLength, + ), + ); + expect(appFlowyBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('**** nothing changes', (tester) async { + const text = '***'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertAsterisk(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, false); + expect(textNode.toRawString(), text); + }); + }); + + group('convert double underscores to bold', () { + Future insertUnderscore( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.underscore, + isShiftPressed: true, + ); + } + } + + testWidgets('__AppFlowy__ to bold AppFlowy', (tester) async { + const text = '__AppFlowy_'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertUnderscore(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('App__Flowy__ to bold AppFlowy', (tester) async { + const text = 'App__Flowy_'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertUnderscore(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 3, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async { + const text = '___AppFlowy_'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertUnderscore(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 1, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, true); + expect(textNode.toRawString(), '_AppFlowy'); + }); + + testWidgets('__AppFlowy__ application to bold AppFlowy only', + (tester) async { + const boldText = '__AppFlowy_'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + + for (var i = 0; i < boldText.length; i++) { + await editor.insertText(textNode, boldText[i], i); + } + await insertUnderscore(editor); + final boldTextLength = boldText.replaceAll('_', '').length; + final appFlowyBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: boldTextLength, + ), + ); + expect(appFlowyBold, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('____ nothing changes', (tester) async { + const text = '___'; + final editor = tester.editor..insertTextNode(''); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.insertText(textNode, text[i], i); + } + await insertUnderscore(editor); + final allBold = textNode.allSatisfyBoldInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allBold, false); + expect(textNode.toRawString(), text); + }); + }); + }); +}