diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart new file mode 100644 index 0000000000..2e1310ca2c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:flutter/widgets.dart'; + +Future insertContextInText( + EditorState editorState, + int index, + String content, { + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..insertText(result, index, content) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart new file mode 100644 index 0000000000..dcce054351 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart @@ -0,0 +1,83 @@ +import 'package:appflowy_editor/src/commands/format_text.dart'; +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; + +Future formatBuiltInTextAttributes( + EditorState editorState, + String key, + Attributes attributes, { + Selection? selection, + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + if (BuiltInAttributeKey.globalStyleKeys.contains(key)) { + // remove all the existing style + final newAttributes = result.attributes + ..removeWhere((key, value) { + if (BuiltInAttributeKey.globalStyleKeys.contains(key)) { + return true; + } + return false; + }) + ..addAll(attributes) + ..addAll({ + BuiltInAttributeKey.subtype: key, + }); + return updateTextNodeAttributes( + editorState, + newAttributes, + textNode: textNode, + ); + } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) { + return updateTextNodeDeltaAttributes( + editorState, + selection, + attributes, + textNode: textNode, + ); + } +} + +Future formatTextToCheckbox( + EditorState editorState, + bool check, { + Path? path, + TextNode? textNode, +}) async { + return formatBuiltInTextAttributes( + editorState, + BuiltInAttributeKey.checkbox, + { + BuiltInAttributeKey.checkbox: check, + }, + path: path, + textNode: textNode, + ); +} + +Future formatLinkInText( + EditorState editorState, + String? link, { + Path? path, + TextNode? textNode, +}) async { + return formatBuiltInTextAttributes( + editorState, + BuiltInAttributeKey.href, + { + BuiltInAttributeKey.href: link, + }, + path: path, + textNode: textNode, + ); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart new file mode 100644 index 0000000000..0ec9e7b61a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:appflowy_editor/src/commands/text_command_infra.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:flutter/widgets.dart'; + +Future updateTextNodeAttributes( + EditorState editorState, + Attributes attributes, { + Path? path, + TextNode? textNode, +}) async { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..updateNode(result, attributes) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} + +Future updateTextNodeDeltaAttributes( + EditorState editorState, + Selection? selection, + Attributes attributes, { + Path? path, + TextNode? textNode, +}) { + final result = getTextNodeToBeFormatted( + editorState, + path: path, + textNode: textNode, + ); + final newSelection = getSelection(editorState, selection: selection); + + final completer = Completer(); + + TransactionBuilder(editorState) + ..formatText( + result, + newSelection.startIndex, + newSelection.length, + attributes, + ) + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + completer.complete(); + }); + + return completer.future; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart new file mode 100644 index 0000000000..d54a84a3e0 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart @@ -0,0 +1,43 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; + +// get formatted [TextNode] +TextNode getTextNodeToBeFormatted( + EditorState editorState, { + Path? path, + TextNode? textNode, +}) { + final currentSelection = + editorState.service.selectionService.currentSelection.value; + TextNode result; + if (textNode != null) { + result = textNode; + } else if (path != null) { + result = editorState.document.nodeAtPath(path) as TextNode; + } else if (currentSelection != null && currentSelection.isCollapsed) { + result = editorState.document.nodeAtPath(currentSelection.start.path) + as TextNode; + } else { + throw Exception('path and textNode cannot be null at the same time'); + } + return result; +} + +Selection getSelection( + EditorState editorState, { + Selection? selection, +}) { + final currentSelection = + editorState.service.selectionService.currentSelection.value; + Selection result; + if (selection != null) { + result = selection; + } else if (currentSelection != null) { + result = currentSelection; + } else { + throw Exception('path and textNode cannot be null at the same time'); + } + return result; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index ea451b46dd..99248dc167 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -53,6 +53,10 @@ class Selection { Selection get reversed => copyWith(start: end, end: start); + int get startIndex => normalize.start.offset; + int get endIndex => normalize.end.offset; + int get length => endIndex - startIndex; + Selection collapse({bool atStart = false}) { if (atStart) { return Selection(start: start, end: start); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 4f2ca39a84..4aeb7ab599 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -72,6 +72,8 @@ class EditorState { // TODO: only for testing. bool disableSealTimer = false; + bool editable = true; + Selection? get cursorSelection { return _cursorSelection; } @@ -112,6 +114,9 @@ class EditorState { /// should record the transaction in undo/redo stack. apply(Transaction transaction, [ApplyOptions options = const ApplyOptions()]) { + if (!editable) { + return; + } // TODO: validate the transation. for (final op in transaction.operations) { _applyOperation(op); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart index 13396a33c4..3a1785391b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart @@ -31,6 +31,7 @@ class _LinkMenuState extends State { void initState() { super.initState(); _textEditingController.text = widget.linkText ?? ''; + _focusNode.requestFocus(); _focusNode.addListener(_onFocusChange); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 2ca7531d2b..10b17d6b36 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -1,15 +1,8 @@ -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; -import 'package:appflowy_editor/src/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/format_built_in_text.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; import 'package:flutter/material.dart'; @@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State padding: iconPadding, name: check ? 'check' : 'uncheck', ), - onTap: () { - formatCheckbox(widget.editorState, !check); + onTap: () async { + await formatTextToCheckbox( + widget.editorState, + !check, + textNode: widget.textNode, + ); }, ), Flexible( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 7dba4852ed..99a6d08918 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; +const _kRichTextDebugMode = false; + typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); class FlowyRichText extends StatefulWidget { @@ -261,6 +263,17 @@ class _FlowyRichTextState extends State with SelectableMixin { ), ); } + if (_kRichTextDebugMode) { + textSpans.add( + TextSpan( + text: '${widget.textNode.path}', + style: const TextStyle( + backgroundColor: Colors.red, + fontSize: 16.0, + ), + ), + ); + } return TextSpan( children: textSpans, ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart index 68bb5023ca..6407f81cb3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/format_built_in_text.dart'; import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; @@ -345,11 +346,8 @@ void showLinkMenu( onOpenLink: () async { await safeLaunchUrl(linkText); }, - onSubmitted: (text) { - TransactionBuilder(editorState) - ..formatText( - textNode, index, length, {BuiltInAttributeKey.href: text}) - ..commit(); + onSubmitted: (text) async { + await formatLinkInText(editorState, text, textNode: textNode); _dismissLinkMenu(); }, onCopyLink: () { @@ -377,6 +375,7 @@ void showLinkMenu( Overlay.of(context)?.insert(_linkMenuOverlay!); editorState.service.scrollService?.disable(); + editorState.service.keyboardService?.disable(); editorState.service.selectionService.currentSelection .addListener(_dismissLinkMenu); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart index 23ddc75a69..053d9e542a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) { final builder = TransactionBuilder(editorState); for (final textNode in textNodes) { + var newAttributes = {...textNode.attributes}; + for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) { + if (newAttributes.keys.contains(globalStyleKey)) { + newAttributes[globalStyleKey] = null; + } + } + newAttributes.addAll(attributes); builder ..updateNode( textNode, - Attributes.fromIterable( - BuiltInAttributeKey.globalStyleKeys, - value: (_) => null, - )..addAll(attributes), + newAttributes, ) ..afterSelection = Selection.collapsed( Position( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 7174290b9c..3d9599383d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State { editorState.selectionMenuItems = widget.selectionMenuItems; editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); + editorState.editable = widget.editable; } @override @@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State { } editorState.editorStyle = widget.editorStyle; + editorState.editable = widget.editable; services = null; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 7f1a4718f5..d6a3420099 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -297,7 +297,11 @@ class _AppFlowyInputState extends State _updateCaretPosition(textNodes.first, selection); } } else { - // close(); + // https://github.com/flutter/flutter/issues/104944 + // Disable IME for the Web. + if (kIsWeb) { + close(); + } } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart new file mode 100644 index 0000000000..6d942135ba --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart @@ -0,0 +1,21 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/commands/edit_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +ShortcutEventHandler spaceOnWebHandler = (editorState, event) { + final selection = editorState.service.selectionService.currentSelection.value; + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType() + .toList(growable: false); + if (selection == null || + !selection.isCollapsed || + !kIsWeb || + textNodes.length != 1) { + return KeyEventResult.ignored; + } + + insertContextInText(editorState, selection.startIndex, ' '); + + 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 38eb9ee7c5..ca614354a0 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 @@ -9,9 +9,11 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; +import 'package:flutter/foundation.dart'; // List builtInShortcutEvents = [ @@ -249,4 +251,14 @@ List builtInShortcutEvents = [ command: 'tab', handler: tabHandler, ), + // 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 + // `input_service`. + if (kIsWeb) + ShortcutEvent( + key: 'Space on the Web', + command: 'space', + handler: spaceOnWebHandler, + ), ]; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart new file mode 100644 index 0000000000..fbbe016d30 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart @@ -0,0 +1,45 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.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('space_on_web_handler.dart', () { + testWidgets('Presses space key on web', (tester) async { + if (!kIsWeb) return; + const count = 10; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 0; i < count; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + + for (var i = 0; i < count; i++) { + await editor.updateSelection( + Selection.single(path: [i], startOffset: 1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([i]) as TextNode).toRawString(), + 'W elcome to Appflowy 😁', + ); + } + for (var i = 0; i < count; i++) { + await editor.updateSelection( + Selection.single(path: [i], startOffset: text.length + 1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([i]) as TextNode).toRawString(), + 'W elcome to Appflowy 😁 ', + ); + } + }); + }); +}