diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart index 352cb69f97..d4d7857286 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart @@ -54,6 +54,11 @@ extension TextNodeExtension on TextNode { return value == true; }); + bool allSatisfyCodeInSelection(Selection selection) => + allSatisfyInSelection(selection, BuiltInAttributeKey.code, (value) { + return value == true; + }); + bool allSatisfyInSelection( Selection selection, String styleKey, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart new file mode 100644 index 0000000000..4b74145ec0 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart @@ -0,0 +1,126 @@ +import "dart:math"; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; +import 'package:flutter/material.dart'; + +bool _isCodeStyle(TextNode textNode, int index) { + return textNode.allSatisfyCodeInSelection(Selection.single( + path: textNode.path, startOffset: index, endOffset: index + 1)); +} + +// enter escape mode when start two backquote +bool _isEscapeBackquote(String text, List backquoteIndexes) { + if (backquoteIndexes.length >= 2) { + final firstBackquoteIndex = backquoteIndexes[0]; + final secondBackquoteIndex = backquoteIndexes[1]; + return firstBackquoteIndex == secondBackquoteIndex - 1; + } + return false; +} + +// find all the index of `, exclusion in code style. +List _findBackquoteIndexes(String text, TextNode textNode) { + final backquoteIndexes = []; + for (var i = 0; i < text.length; i++) { + if (text[i] == '`' && _isCodeStyle(textNode, i) == false) { + backquoteIndexes.add(i); + } + } + return backquoteIndexes; +} + +/// To denote a word or phrase as code, enclose it in backticks (`). +/// If the word or phrase you want to denote as code includes one or more +/// backticks, you can escape it by enclosing the word or phrase in double +/// backticks (``). +ShortcutEventHandler backquoteToCodeHandler = (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 selectionText = textNode + .toRawString() + .substring(selection.start.offset, selection.end.offset); + + // toggle code style when selected some text + if (selectionText.length > 0) { + formatEmbedCode(editorState); + return KeyEventResult.handled; + } + + final text = textNode.toRawString().substring(0, selection.end.offset); + final backquoteIndexes = _findBackquoteIndexes(text, textNode); + if (backquoteIndexes.isEmpty) { + return KeyEventResult.ignored; + } + + final endIndex = selection.end.offset; + + if (_isEscapeBackquote(text, backquoteIndexes)) { + final firstBackquoteIndex = backquoteIndexes[0]; + final secondBackquoteIndex = backquoteIndexes[1]; + final lastBackquoteIndex = backquoteIndexes[backquoteIndexes.length - 1]; + if (secondBackquoteIndex == lastBackquoteIndex || + secondBackquoteIndex == lastBackquoteIndex - 1 || + lastBackquoteIndex != endIndex - 1) { + // ``(`),```(`),``...`...(`) should ignored + return KeyEventResult.ignored; + } + + TransactionBuilder(editorState) + ..deleteText(textNode, lastBackquoteIndex, 1) + ..deleteText(textNode, firstBackquoteIndex, 2) + ..formatText( + textNode, + firstBackquoteIndex, + endIndex - firstBackquoteIndex - 3, + { + BuiltInAttributeKey.code: true, + }, + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: endIndex - 3, + ), + ) + ..commit(); + + return KeyEventResult.handled; + } + + // handle single backquote + final startIndex = backquoteIndexes[0]; + if (startIndex == endIndex - 1) { + return KeyEventResult.ignored; + } + + // delete the backquote. + // update the style of the text surround by ` ` to code. + // and update the cursor position. + TransactionBuilder(editorState) + ..deleteText(textNode, startIndex, 1) + ..formatText( + textNode, + startIndex, + endIndex - startIndex - 1, + { + BuiltInAttributeKey.code: true, + }, + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: endIndex - 1, + ), + ) + ..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 ca614354a0..827b057700 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.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'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; @@ -251,6 +252,11 @@ List builtInShortcutEvents = [ command: 'tab', handler: tabHandler, ), + ShortcutEvent( + key: 'Backquote to code', + command: 'backquote', + handler: backquoteToCodeHandler, + ), // https://github.com/flutter/flutter/issues/104944 // Workaround: Using space editing on the web platform often results in errors, // so adding a shortcut event to handle the space input instead of using the diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart new file mode 100644 index 0000000000..d11a955643 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart @@ -0,0 +1,154 @@ +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.dart', () { + group('convert single backquote to code', () { + Future insertBackquote( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.backquote, + ); + } + } + + testWidgets('`AppFlowy` to code 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 insertBackquote(editor); + final allCode = textNode.allSatisfyCodeInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allCode, true); + expect(textNode.toRawString(), 'AppFlowy'); + }); + + testWidgets('App`Flowy` to code 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 insertBackquote(editor); + final allCode = textNode.allSatisfyCodeInSelection( + Selection.single( + path: [0], + startOffset: 3, + endOffset: textNode.toRawString().length, + ), + ); + expect(allCode, 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 insertBackquote(editor); + final allCode = textNode.allSatisfyCodeInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allCode, false); + expect(textNode.toRawString(), text); + }); + }); + + group('convert double backquote to code', () { + Future insertBackquote( + EditorWidgetTester editor, { + int repeat = 1, + }) async { + for (var i = 0; i < repeat; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.backquote, + ); + } + } + + testWidgets('```AppFlowy`` to code `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 insertBackquote(editor); + final allCode = textNode.allSatisfyCodeInSelection( + Selection.single( + path: [0], + startOffset: 1, + endOffset: textNode.toRawString().length, + ), + ); + expect(allCode, 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 insertBackquote(editor); + final allCode = textNode.allSatisfyCodeInSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: textNode.toRawString().length, + ), + ); + expect(allCode, false); + expect(textNode.toRawString(), text); + }); + }); + }); +}