From a3bd9fd0e1c854fee9265969a5b36bb0801538df Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 10:06:33 +0800 Subject: [PATCH 1/7] feat: expand rich text to handle gestures. (cherry picked from commit ce1eca28e1b9a5ef443f1123ac1b33d89efb6788) --- .../flowy_editor/lib/render/rich_text/flowy_rich_text.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index f302fcaba8..4ffb4528b4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -129,7 +129,10 @@ class _FlowyRichTextState extends State with Selectable { } Widget _buildRichText(BuildContext context) { - return _buildSingleRichText(context); + return Align( + alignment: Alignment.centerLeft, + child: _buildSingleRichText(context), + ); } Widget _buildSingleRichText(BuildContext context) { From 35bafbaadc55543a6b8a30e9cbe64c026db34949 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 16:34:11 +0800 Subject: [PATCH 2/7] feat: implement popup list service --- .../assets/images/popup_list/bullets.svg | 8 + .../assets/images/popup_list/checkbox.svg | 4 + .../assets/images/popup_list/h1.svg | 4 + .../assets/images/popup_list/h2.svg | 4 + .../assets/images/popup_list/h3.svg | 4 + .../assets/images/popup_list/number.svg | 3 + .../assets/images/popup_list/text.svg | 4 + .../assets/images/toolbar/code.svg | 4 + .../flowy_editor/example/pubspec.lock | 7 + .../flowy_editor/example/pubspec.yaml | 1 + .../lib/extensions/node_extensions.dart | 1 + .../flowy_editor/lib/infra/flowy_svg.dart | 20 +- .../lib/render/selection/toolbar_widget.dart | 1 + .../lib/service/editor_service.dart | 2 +- .../shortcut_handler.dart | 12 - .../slash_handler.dart | 223 ++++++++++++++++++ .../packages/flowy_editor/pubspec.yaml | 1 + 17 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg create mode 100644 frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg delete mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg new file mode 100644 index 0000000000..97a2e9c434 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg new file mode 100644 index 0000000000..37f52c47ed --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg new file mode 100644 index 0000000000..6e97796956 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg new file mode 100644 index 0000000000..2c1d1d9d1c --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg new file mode 100644 index 0000000000..8c6276263d --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg new file mode 100644 index 0000000000..9d8b98d10d --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg new file mode 100644 index 0000000000..7befa5080f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg b/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg new file mode 100644 index 0000000000..9b96b3c2dc --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock index cfadcb8242..83334af630 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock @@ -69,6 +69,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.3+7" flutter_lints: dependency: "direct dev" description: diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml index 9a80a73a0a..0c58de8b7d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: path: ../ provider: ^6.0.3 url_launcher: ^6.1.5 + flutter_inappwebview: ^5.4.3+7 dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart index 52b7596240..b421b258b6 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart @@ -9,6 +9,7 @@ extension NodeExtensions on Node { RenderBox? get renderBox => key?.currentContext?.findRenderObject()?.unwrapOrNull(); + BuildContext? get context => key?.currentContext; Selectable? get selectable => key?.currentState?.unwrapOrNull(); bool inSelection(Selection selection) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart index 136b5db4bc..12da5b5dc8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart @@ -18,20 +18,20 @@ class FlowySvg extends StatelessWidget { @override Widget build(BuildContext context) { if (name != null) { - return SizedBox.fromSize( - size: size, - child: SvgPicture.asset( - 'assets/images/$name.svg', - color: color, - package: 'flowy_editor', - ), + return SvgPicture.asset( + 'assets/images/$name.svg', + color: color, + package: 'flowy_editor', + width: size.width, + height: size.width, ); } else if (number != null) { final numberText = '$number.'; - return SizedBox.fromSize( - size: size, - child: SvgPicture.string(numberText), + return SvgPicture.string( + numberText, + width: size.width, + height: size.width, ); } return Container(); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart index 91659e1d1f..1314260bca 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart @@ -201,6 +201,7 @@ class _ToolbarWidgetState extends State { ), ); }); + // TODO: disable scrolling. Overlay.of(context)?.insert(_listToolbarOverlay!); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index b62fe1bb15..c98b21c17a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -14,7 +14,7 @@ import 'package:flowy_editor/service/input_service.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/slash_handler.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart deleted file mode 100644 index f424bcf314..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// type '/' to trigger shortcut widget -FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.slash) { - return KeyEventResult.ignored; - } - - return KeyEventResult.ignored; -}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart new file mode 100644 index 0000000000..d896f81eb6 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart @@ -0,0 +1,223 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/extensions/node_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +final List _popupListItems = [ + PopupListItem( + text: 'Text', + icon: _popupListIcon('text'), + handler: (editorState) => formatText(editorState), + ), + PopupListItem( + text: 'Heading 1', + icon: _popupListIcon('h1'), + handler: (editorState) => formatHeading(editorState, StyleKey.h1), + ), + PopupListItem( + text: 'Heading 2', + icon: _popupListIcon('h2'), + handler: (editorState) => formatHeading(editorState, StyleKey.h2), + ), + PopupListItem( + text: 'Heading 3', + icon: _popupListIcon('h3'), + handler: (editorState) => formatHeading(editorState, StyleKey.h3), + ), + PopupListItem( + text: 'Bullets', + icon: _popupListIcon('bullets'), + handler: (editorState) => formatBulletedList(editorState), + ), + PopupListItem( + text: 'Numbered list', + icon: _popupListIcon('number'), + handler: (editorState) => debugPrint('Not implement yet!'), + ), + PopupListItem( + text: 'Checkboxes', + icon: _popupListIcon('checkbox'), + handler: (editorState) => formatCheckbox(editorState), + ), +]; + +OverlayEntry? popupListOverlay; +FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { + if (event.logicalKey != LogicalKeyboardKey.slash && !event.isMetaPressed) { + return KeyEventResult.ignored; + } + + final textNodes = editorState + .service.selectionService.currentSelectedNodes.value + .whereType(); + if (textNodes.length != 1) { + return KeyEventResult.ignored; + } + + final selection = editorState.service.selectionService.currentSelection; + final textNode = textNodes.first; + final context = textNode.context; + final selectable = textNode.selectable; + if (selection == null || context == null || selectable == null) { + return KeyEventResult.ignored; + } + + final rect = selectable.getCursorRectInPosition(selection.start); + final offset = selectable.localToGlobal(rect.topLeft); + if (!selection.isCollapsed) { + TransactionBuilder(editorState) + ..deleteText( + textNode, + selection.start.offset, + selection.end.offset - selection.start.offset, + ) + ..commit(); + } + + popupListOverlay?.remove(); + popupListOverlay = OverlayEntry( + builder: (context) => Positioned( + top: offset.dy + 15.0, + left: offset.dx, + child: PopupListWidget( + editorState: editorState, + items: _popupListItems, + ), + ), + ); + + Overlay.of(context)?.insert(popupListOverlay!); + + editorState.service.selectionService.currentSelectedNodes + .removeListener(clearPopupListOverlay); + editorState.service.selectionService.currentSelectedNodes + .addListener(clearPopupListOverlay); + + return KeyEventResult.handled; +}; + +void clearPopupListOverlay() { + popupListOverlay?.remove(); + popupListOverlay = null; +} + +class PopupListWidget extends StatefulWidget { + const PopupListWidget({ + Key? key, + required this.editorState, + required this.items, + this.maxItemInRow = 8, + }) : super(key: key); + + final EditorState editorState; + final List items; + final int maxItemInRow; + + @override + State createState() => _PopupListWidgetState(); +} + +class _PopupListWidgetState extends State { + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildColumns(widget.items), + ), + ); + } + + List _buildColumns(List items) { + List columns = []; + List itemWidgets = []; + for (var i = 0; i < items.length; i++) { + if (i != 0 && i % (widget.maxItemInRow - 1) == 0) { + columns.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + )); + itemWidgets = []; + } + itemWidgets.add(_PopupListItemWidget( + editorState: widget.editorState, item: items[i])); + } + if (itemWidgets.isNotEmpty) { + columns.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + )); + itemWidgets = []; + } + return columns; + } +} + +class _PopupListItemWidget extends StatelessWidget { + const _PopupListItemWidget({ + Key? key, + required this.item, + required this.editorState, + }) : super(key: key); + + final EditorState editorState; + final PopupListItem item; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0), + child: TextButton.icon( + icon: item.icon, + label: Text( + item.text, + textAlign: TextAlign.left, + style: const TextStyle( + color: Colors.black, + fontSize: 14.0, + ), + ), + onPressed: () { + item.handler(editorState); + }, + ), + ); + } +} + +class PopupListItem { + PopupListItem({ + required this.text, + this.message = '', + required this.icon, + required this.handler, + }); + + final String text; + final String message; + final Widget icon; + final void Function(EditorState editorState) handler; +} + +Widget _popupListIcon(String name) => FlowySvg( + name: 'popup_list/$name', + color: Colors.black, + size: const Size.square(18.0), + ); diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index db0eef5296..d828d5501e 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml @@ -27,6 +27,7 @@ flutter: # To add assets to your package, add an assets section, like this: assets: - assets/images/toolbar/ + - assets/images/popup_list/ - assets/images/ - assets/document.json # - images/a_dot_burr.jpeg From 1166d03b7558d08ef9039c33f282b065fe801af5 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 17:05:09 +0800 Subject: [PATCH 3/7] feat: update selection position after updating text node style. --- .../format_rich_text_style.dart | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart index 79e7bfe077..8e0e3e35b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart @@ -1,5 +1,7 @@ import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/extensions/text_node_extensions.dart'; import 'package:flowy_editor/operation/transaction_builder.dart'; @@ -46,13 +48,20 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) { final builder = TransactionBuilder(editorState); for (final textNode in textNodes) { - builder.updateNode( - textNode, - Attributes.fromIterable( - StyleKey.globalStyleKeys, - value: (_) => null, - )..addAll(attributes), - ); + builder + ..updateNode( + textNode, + Attributes.fromIterable( + StyleKey.globalStyleKeys, + value: (_) => null, + )..addAll(attributes), + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: textNode.toRawString().length, + ), + ); } builder.commit(); From 58d656d9f4811d85233f26310d76928b5eedc820 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 22:12:09 +0800 Subject: [PATCH 4/7] feat: implement selectable popup list widget --- .../slash_handler.dart | 155 +++++++++++++----- .../lib/service/keyboard_service.dart | 33 +++- .../flowy_editor/lib/service/service.dart | 8 + 3 files changed, 157 insertions(+), 39 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart index d896f81eb6..02975df1e1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/infra/flowy_svg.dart'; @@ -47,9 +49,10 @@ final List _popupListItems = [ ), ]; -OverlayEntry? popupListOverlay; +OverlayEntry? _popupListOverlay; +EditorState? _editorState; FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.slash && !event.isMetaPressed) { + if (event.logicalKey != LogicalKeyboardKey.slash) { return KeyEventResult.ignored; } @@ -80,11 +83,11 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { ..commit(); } - popupListOverlay?.remove(); - popupListOverlay = OverlayEntry( + _popupListOverlay?.remove(); + _popupListOverlay = OverlayEntry( builder: (context) => Positioned( top: offset.dy + 15.0, - left: offset.dx, + left: offset.dx + 5.0, child: PopupListWidget( editorState: editorState, items: _popupListItems, @@ -92,19 +95,24 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { ), ); - Overlay.of(context)?.insert(popupListOverlay!); + Overlay.of(context)?.insert(_popupListOverlay!); editorState.service.selectionService.currentSelectedNodes .removeListener(clearPopupListOverlay); editorState.service.selectionService.currentSelectedNodes .addListener(clearPopupListOverlay); + // editorState.service.keyboardService?.disable(); + _editorState = editorState; return KeyEventResult.handled; }; void clearPopupListOverlay() { - popupListOverlay?.remove(); - popupListOverlay = null; + _popupListOverlay?.remove(); + _popupListOverlay = null; + + _editorState?.service.keyboardService?.enable(); + _editorState = null; } class PopupListWidget extends StatefulWidget { @@ -112,7 +120,7 @@ class PopupListWidget extends StatefulWidget { Key? key, required this.editorState, required this.items, - this.maxItemInRow = 8, + this.maxItemInRow = 5, }) : super(key: key); final EditorState editorState; @@ -124,32 +132,57 @@ class PopupListWidget extends StatefulWidget { } class _PopupListWidgetState extends State { + final focusNode = FocusNode(debugLabel: 'popup_list_widget'); + var selectedIndex = 0; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void dispose() { + focusNode.dispose(); + + super.dispose(); + } + @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildColumns(widget.items), + // TODO: Is there a better way to get focus? + + return Focus( + focusNode: focusNode, + onKey: _onKey, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildColumns(widget.items, selectedIndex), + ), ), ); } - List _buildColumns(List items) { + List _buildColumns(List items, int selectedIndex) { List columns = []; List itemWidgets = []; for (var i = 0; i < items.length; i++) { - if (i != 0 && i % (widget.maxItemInRow - 1) == 0) { + if (i != 0 && i % (widget.maxItemInRow) == 0) { columns.add(Column( crossAxisAlignment: CrossAxisAlignment.start, children: itemWidgets, @@ -157,7 +190,10 @@ class _PopupListWidgetState extends State { itemWidgets = []; } itemWidgets.add(_PopupListItemWidget( - editorState: widget.editorState, item: items[i])); + editorState: widget.editorState, + item: items[i], + highlight: selectedIndex == i, + )); } if (itemWidgets.isNotEmpty) { columns.add(Column( @@ -168,35 +204,78 @@ class _PopupListWidgetState extends State { } return columns; } + + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.enter) { + widget.items[selectedIndex].handler(widget.editorState); + return KeyEventResult.handled; + } + + var newSelectedIndex = selectedIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + newSelectedIndex -= widget.maxItemInRow; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + newSelectedIndex += widget.maxItemInRow; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + newSelectedIndex -= 1; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + newSelectedIndex += 1; + } + if (newSelectedIndex != selectedIndex) { + setState(() { + selectedIndex = max(0, min(widget.items.length - 1, newSelectedIndex)); + }); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } } class _PopupListItemWidget extends StatelessWidget { const _PopupListItemWidget({ Key? key, + required this.highlight, required this.item, required this.editorState, }) : super(key: key); final EditorState editorState; final PopupListItem item; + final bool highlight; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0), - child: TextButton.icon( - icon: item.icon, - label: Text( - item.text, - textAlign: TextAlign.left, - style: const TextStyle( - color: Colors.black, - fontSize: 14.0, + child: SizedBox( + width: 140, + child: TextButton.icon( + icon: item.icon, + style: ButtonStyle( + alignment: Alignment.centerLeft, + overlayColor: MaterialStateProperty.all( + const Color(0xFFE0F8FF), + ), + backgroundColor: highlight + ? MaterialStateProperty.all(const Color(0xFFE0F8FF)) + : MaterialStateProperty.all(Colors.transparent), ), + label: Text( + item.text, + textAlign: TextAlign.left, + style: const TextStyle( + color: Colors.black, + fontSize: 14.0, + ), + ), + onPressed: () { + item.handler(editorState); + }, ), - onPressed: () { - item.handler(editorState); - }, ), ); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart index ebd66894a7..572babeb3a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart @@ -3,6 +3,11 @@ import 'package:flutter/services.dart'; import '../editor_state.dart'; import 'package:flutter/material.dart'; +mixin FlowyKeyboardService on State { + void enable(); + void disable(); +} + typedef FlowyKeyEventHandler = KeyEventResult Function( EditorState editorState, RawKeyEvent event, @@ -25,9 +30,12 @@ class FlowyKeyboard extends StatefulWidget { State createState() => _FlowyKeyboardState(); } -class _FlowyKeyboardState extends State { +class _FlowyKeyboardState extends State + with FlowyKeyboardService { final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); + bool isFocus = true; + @override Widget build(BuildContext context) { return Focus( @@ -38,7 +46,30 @@ class _FlowyKeyboardState extends State { ); } + @override + void dispose() { + focusNode.dispose(); + + super.dispose(); + } + + @override + void enable() { + isFocus = true; + focusNode.requestFocus(); + } + + @override + void disable() { + isFocus = false; + focusNode.unfocus(); + } + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + if (!isFocus) { + return KeyEventResult.ignored; + } + debugPrint('on keyboard event $event'); if (event is! RawKeyDownEvent) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart index 937a16044a..d9920423ae 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart @@ -1,3 +1,4 @@ +import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; import 'package:flowy_editor/service/toolbar_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; @@ -14,6 +15,13 @@ class FlowyService { // keyboard service final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service'); + FlowyKeyboardService? get keyboardService { + if (keyboardServiceKey.currentState != null && + keyboardServiceKey.currentState is FlowyKeyboardService) { + return keyboardServiceKey.currentState! as FlowyKeyboardService; + } + return null; + } // input service final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); From eb7c65aa8b490c4b1852e6899d86872b6bbe060e Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 22:12:52 +0800 Subject: [PATCH 5/7] feat: add empty document entry in example --- .../flowy_editor/example/lib/main.dart | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 1a68f38ead..856c07e900 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:convert'; import 'package:example/expandable_floating_action_button.dart'; @@ -80,12 +81,21 @@ class _MyHomePageState extends State { icon: const Icon(Icons.note_add), ), ActionButton( + icon: const Icon(Icons.document_scanner), onPressed: () { if (page == 1) return; setState(() { page = 1; }); }, + ), + ActionButton( + onPressed: () { + if (page == 2) return; + setState(() { + page = 2; + }); + }, icon: const Icon(Icons.text_fields), ), ], @@ -97,11 +107,41 @@ class _MyHomePageState extends State { if (page == 0) { return _buildFlowyEditor(); } else if (page == 1) { + return _buildFlowyEditorWithEmptyDocument(); + } else if (page == 2) { return _buildTextField(); } return Container(); } + Widget _buildFlowyEditorWithEmptyDocument() { + return Container( + padding: const EdgeInsets.only(left: 20, right: 20), + child: FlowyEditor( + key: editorKey, + editorState: EditorState( + document: StateTree( + root: Node( + type: 'editor', + children: LinkedList() + ..add( + TextNode.empty() + ..delta = Delta( + [TextInsert('')], + ), + ), + attributes: {}, + ), + ), + ), + keyEventHandlers: const [], + customBuilders: { + 'image': ImageNodeBuilder(), + }, + ), + ); + } + Widget _buildFlowyEditor() { return FutureBuilder( future: rootBundle.loadString('assets/example.json'), From 90fa1312f2b32142fda08397038287009b66616c Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 3 Aug 2022 22:53:19 +0800 Subject: [PATCH 6/7] fix: update the selection after render completed --- .../app_flowy/packages/flowy_editor/lib/editor_state.dart | 4 +--- .../service/internal_key_event_handlers/slash_handler.dart | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index 92a05fc880..5ea49c644d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -60,10 +60,8 @@ class EditorState { for (final op in transaction.operations) { _applyOperation(op); } - // updateCursorSelection(transaction.afterSelection); - // FIXME: don't use delay - Future.delayed(const Duration(milliseconds: 16), () { + WidgetsBinding.instance.addPostFrameCallback((_) { updateCursorSelection(transaction.afterSelection); }); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart index 02975df1e1..db3db2e1ad 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart @@ -153,8 +153,6 @@ class _PopupListWidgetState extends State { @override Widget build(BuildContext context) { - // TODO: Is there a better way to get focus? - return Focus( focusNode: focusNode, onKey: _onKey, From e5787090d299d1cbcb4774a801d67653c22eac13 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 5 Aug 2022 10:59:51 +0800 Subject: [PATCH 7/7] feat: implement autoscrolling on edge touch --- .../flowy_editor/example/assets/example.json | 56 ++++ .../lib/render/editor/editor_entry.dart | 45 ++- .../lib/render/rich_text/flowy_rich_text.dart | 23 +- .../lib/service/editor_service.dart | 65 ++-- .../lib/service/scroll_service.dart | 65 ++++ .../lib/service/selection_service.dart | 302 +++++++++++------- .../flowy_editor/lib/service/service.dart | 22 +- .../lib/service/toolbar_service.dart | 9 +- 8 files changed, 403 insertions(+), 184 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json index c69237f24f..b6fc3467dc 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json @@ -241,6 +241,62 @@ "subtype": "number-list", "number": 3 } + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] } ] } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart index 650732f9f9..fa32743b02 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart @@ -1,7 +1,8 @@ +import 'package:flutter/material.dart'; + import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; class EditorEntryWidgetBuilder extends NodeWidgetBuilder { @override @@ -31,28 +32,26 @@ class EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (child) => - editorState.service.renderPluginService.buildPluginWidget( - child is TextNode - ? NodeWidgetContext( - context: context, - node: child, - editorState: editorState, - ) - : NodeWidgetContext( - context: context, - node: child, - editorState: editorState, - ), - ), - ) - .toList(), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children + .map( + (child) => + editorState.service.renderPluginService.buildPluginWidget( + child is TextNode + ? NodeWidgetContext( + context: context, + node: child, + editorState: editorState, + ) + : NodeWidgetContext( + context: context, + node: child, + editorState: editorState, + ), + ), + ) + .toList(), ); } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index 4ffb4528b4..83d809745c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -1,16 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; import 'package:flowy_editor/render/selection/selectable.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { @override Widget build(NodeWidgetContext context) { @@ -173,11 +173,12 @@ class _FlowyRichTextState extends State with Selectable { } TextSpan get _textSpan => TextSpan( - children: widget.textNode.delta.operations - .whereType() - .map((insert) => RichTextStyle( - attributes: insert.attributes ?? {}, - text: insert.content, - ).toTextSpan()) - .toList(growable: false)); + children: widget.textNode.delta.operations + .whereType() + .map((insert) => RichTextStyle( + attributes: insert.attributes ?? {}, + text: insert.content, + ).toTextSpan()) + .toList(growable: false), + ); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index c98b21c17a..d68db05adc 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -1,5 +1,4 @@ -import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/editor_state.dart'; @@ -13,10 +12,13 @@ import 'package:flowy_editor/render/rich_text/quoted_text.dart'; import 'package:flowy_editor/service/input_service.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/slash_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flowy_editor/service/scroll_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; import 'package:flowy_editor/service/toolbar_service.dart'; @@ -60,15 +62,25 @@ class FlowyEditor extends StatefulWidget { } class _FlowyEditorState extends State { + late ScrollController _scrollController; + EditorState get editorState => widget.editorState; @override void initState() { super.initState(); + _scrollController = ScrollController()..addListener(_scrollCallback); editorState.service.renderPluginService = _createRenderPlugin(); } + @override + void dispose() { + _scrollController.dispose(); + + super.dispose(); + } + @override void didUpdateWidget(covariant FlowyEditor oldWidget) { super.didUpdateWidget(oldWidget); @@ -80,33 +92,36 @@ class _FlowyEditorState extends State { @override Widget build(BuildContext context) { - return FlowySelection( - key: editorState.service.selectionServiceKey, - editorState: editorState, - child: FlowyInput( - key: editorState.service.inputServiceKey, - editorState: editorState, - child: FlowyKeyboard( - key: editorState.service.keyboardServiceKey, - handlers: [ - ...defaultKeyEventHandler, - ...widget.keyEventHandlers, - ], + return FlowyScroll( + key: editorState.service.scrollServiceKey, + child: FlowySelection( + key: editorState.service.selectionServiceKey, editorState: editorState, - child: FlowyToolbar( - key: editorState.service.toolbarServiceKey, + child: FlowyInput( + key: editorState.service.inputServiceKey, editorState: editorState, - child: editorState.service.renderPluginService.buildPluginWidget( - NodeWidgetContext( - context: context, - node: editorState.document.root, + child: FlowyKeyboard( + key: editorState.service.keyboardServiceKey, + handlers: [ + ...defaultKeyEventHandler, + ...widget.keyEventHandlers, + ], + editorState: editorState, + child: FlowyToolbar( + key: editorState.service.toolbarServiceKey, editorState: editorState, + child: + editorState.service.renderPluginService.buildPluginWidget( + NodeWidgetContext( + context: context, + node: editorState.document.root, + editorState: editorState, + ), + ), ), ), ), - ), - ), - ); + )); } FlowyRenderPlugin _createRenderPlugin() => FlowyRenderPlugin( @@ -116,4 +131,8 @@ class _FlowyEditorState extends State { ...widget.customBuilders, }, ); + + void _scrollCallback() { + debugPrint('scrolling'); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart new file mode 100644 index 0000000000..c3a0a6fedc --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart @@ -0,0 +1,65 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +mixin FlowyScrollService on State { + double get dy; + + void scrollTo(double dy); + + RenderObject? scrollRenderObject(); +} + +class FlowyScroll extends StatefulWidget { + const FlowyScroll({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + State createState() => _FlowyScrollState(); +} + +class _FlowyScrollState extends State with FlowyScrollService { + final _scrollController = ScrollController(); + final _scrollViewKey = GlobalKey(); + + @override + double get dy => _scrollController.position.pixels; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerSignal: _onPointerSignal, + child: SingleChildScrollView( + key: _scrollViewKey, + physics: const NeverScrollableScrollPhysics(), + controller: _scrollController, + child: widget.child, + ), + ); + } + + @override + void scrollTo(double dy) { + _scrollController.position.jumpTo( + dy.clamp( + _scrollController.position.minScrollExtent, + _scrollController.position.maxScrollExtent, + ), + ); + } + + void _onPointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent) { + final dy = (_scrollController.position.pixels + event.scrollDelta.dy); + scrollTo(dy); + } + } + + @override + RenderObject? scrollRenderObject() { + return _scrollViewKey.currentContext?.findRenderObject(); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 59632773e5..b879ea419a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -99,102 +100,18 @@ class FlowySelection extends StatefulWidget { State createState() => _FlowySelectionState(); } -/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] -/// for a while. So we need to implement our own GestureDetector. -@immutable -class _SelectionGestureDetector extends StatefulWidget { - const _SelectionGestureDetector( - {Key? key, - this.child, - this.onTapDown, - this.onDoubleTapDown, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd}) - : super(key: key); - - @override - State<_SelectionGestureDetector> createState() => - _SelectionGestureDetectorState(); - - final Widget? child; - - final GestureTapDownCallback? onTapDown; - final GestureTapDownCallback? onDoubleTapDown; - final GestureDragStartCallback? onPanStart; - final GestureDragUpdateCallback? onPanUpdate; - final GestureDragEndCallback? onPanEnd; -} - -class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> { - bool _isDoubleTap = false; - Timer? _doubleTapTimer; - @override - Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = widget.onPanStart - ..onUpdate = widget.onPanUpdate - ..onEnd = widget.onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) { - recognizer.onTapDown = _tapDownDelegate; - }, - ), - }, - child: widget.child, - ); - } - - _tapDownDelegate(TapDownDetails tapDownDetails) { - if (_isDoubleTap) { - _isDoubleTap = false; - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; - if (widget.onDoubleTapDown != null) { - widget.onDoubleTapDown!(tapDownDetails); - } - } else { - if (widget.onTapDown != null) { - widget.onTapDown!(tapDownDetails); - } - - _isDoubleTap = true; - _doubleTapTimer?.cancel(); - _doubleTapTimer = Timer(kDoubleTapTimeout, () { - _isDoubleTap = false; - _doubleTapTimer = null; - }); - } - } - - @override - void dispose() { - _doubleTapTimer?.cancel(); - super.dispose(); - } -} - class _FlowySelectionState extends State with FlowySelectionService, WidgetsBindingObserver { final _cursorKey = GlobalKey(debugLabel: 'cursor'); final List _selectionOverlays = []; final List _cursorOverlays = []; + OverlayEntry? _debugOverlay; /// [Pan] and [Tap] must be mutually exclusive. /// Pan Offset? panStartOffset; + double? panStartScrollDy; Offset? panEndOffset; /// Tap @@ -259,7 +176,7 @@ class _FlowySelectionState extends State @override void updateSelection(Selection selection) { _rects.clear(); - _clearSelection(); + clearSelection(); // cursor if (selection.isCollapsed) { @@ -273,7 +190,19 @@ class _FlowySelectionState extends State @override void clearSelection() { - _clearSelection(); + currentSelection = null; + currentSelectedNodes.value = []; + + // clear selection + _selectionOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + // clear cursors + _cursorOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + // clear toolbar + editorState.service.toolbarService?.hide(); } @override @@ -325,7 +254,7 @@ class _FlowySelectionState extends State } } for (final child in node.children) { - result.addAll(computeNodesInRange(child, start, end)); + result.addAll(_computeNodesInRange(child, start, end)); } return result; } @@ -411,12 +340,24 @@ class _FlowySelectionState extends State clearSelection(); panStartOffset = details.globalPosition; + panStartScrollDy = editorState.service.scrollService?.dy; + + debugPrint('[_onPanStart] panStartOffset = $panStartOffset'); } void _onPanUpdate(DragUpdateDetails details) { - panEndOffset = details.globalPosition; + if (panStartOffset == null || panStartScrollDy == null) { + return; + } - final nodes = getNodesInRange(panStartOffset!, panEndOffset!); + panEndOffset = details.globalPosition; + final dy = editorState.service.scrollService?.dy; + var panStartOffsetWithScrollDyGap = panStartOffset!; + if (dy != null) { + panStartOffsetWithScrollDyGap = + panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy); + } + final nodes = getNodesInRange(panStartOffsetWithScrollDyGap, panEndOffset!); if (nodes.isEmpty) { return; } @@ -427,40 +368,30 @@ class _FlowySelectionState extends State if (first != null && last != null) { bool isDownward; if (first == last) { - isDownward = panStartOffset!.dx < panEndOffset!.dx; + isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx; } else { - isDownward = panStartOffset!.dy < panEndOffset!.dy; + isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy; } - final start = - first.getSelectionInRange(panStartOffset!, panEndOffset!).start; - final end = last.getSelectionInRange(panStartOffset!, panEndOffset!).end; + final start = first + .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) + .start; + final end = last + .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) + .end; final selection = Selection( start: isDownward ? start : end, end: isDownward ? end : start); debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); editorState.service.selectionService.updateSelection(selection); } + + _scrollUpOrDownIfNeeded(panEndOffset!); + _showDebugLayerIfNeeded(); } void _onPanEnd(DragEndDetails details) { // do nothing } - void _clearSelection() { - currentSelection = null; - currentSelectedNodes.value = []; - - // clear selection - _selectionOverlays - ..forEach((overlay) => overlay.remove()) - ..clear(); - // clear cursors - _cursorOverlays - ..forEach((overlay) => overlay.remove()) - ..clear(); - // clear toolbar - editorState.service.toolbarService?.hide(); - } - void _updateSelection(Selection selection) { final nodes = _selectedNodesInSelection(editorState.document.root, selection); @@ -554,12 +485,12 @@ class _FlowySelectionState extends State if (rect != null) { _rects.add(_transformRectToGlobal(selectable!, rect)); final cursor = OverlayEntry( - builder: ((context) => CursorWidget( - key: _cursorKey, - rect: rect, - color: widget.cursorColor, - layerLink: node.layerLink, - )), + builder: (context) => CursorWidget( + key: _cursorKey, + rect: rect, + color: widget.cursorColor, + layerLink: node.layerLink, + ), ); _cursorOverlays.add(cursor); Overlay.of(context)?.insertAll(_cursorOverlays); @@ -584,4 +515,139 @@ class _FlowySelectionState extends State } return result; } + + void _scrollUpOrDownIfNeeded(Offset offset) { + final dy = editorState.service.scrollService?.dy; + if (dy == null) { + assert(false, 'Dy could not be null'); + return; + } + final topLimit = MediaQuery.of(context).size.height * 0.2; + final bottomLimit = MediaQuery.of(context).size.height * 0.8; + + /// TODO: It is necessary to calculate the relative speed + /// according to the gap and move forward more gently. + final distance = 10.0; + if (offset.dy <= topLimit) { + // up + editorState.service.scrollService?.scrollTo(dy - distance); + } else if (offset.dy >= bottomLimit) { + //down + editorState.service.scrollService?.scrollTo(dy + distance); + } + } + + void _showDebugLayerIfNeeded() { + // remove false to show debug overlay. + if (kDebugMode && false) { + _debugOverlay?.remove(); + if (panStartOffset != null) { + _debugOverlay = OverlayEntry( + builder: (context) => Positioned.fromRect( + rect: Rect.fromPoints( + panStartOffset?.translate( + 0, + -(editorState.service.scrollService!.dy - + panStartScrollDy!), + ) ?? + Offset.zero, + panEndOffset ?? Offset.zero) + .translate(0, 0), + child: Container( + color: Colors.red.withOpacity(0.2), + ), + ), + ); + Overlay.of(context)?.insert(_debugOverlay!); + } else { + _debugOverlay = null; + } + } + } +} + +/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] +/// for a while. So we need to implement our own GestureDetector. +@immutable +class _SelectionGestureDetector extends StatefulWidget { + const _SelectionGestureDetector( + {Key? key, + this.child, + this.onTapDown, + this.onDoubleTapDown, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd}) + : super(key: key); + + @override + State<_SelectionGestureDetector> createState() => + _SelectionGestureDetectorState(); + + final Widget? child; + + final GestureTapDownCallback? onTapDown; + final GestureTapDownCallback? onDoubleTapDown; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; +} + +class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> { + bool _isDoubleTap = false; + Timer? _doubleTapTimer; + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = widget.onPanStart + ..onUpdate = widget.onPanUpdate + ..onEnd = widget.onPanEnd; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) { + recognizer.onTapDown = _tapDownDelegate; + }, + ), + }, + child: widget.child, + ); + } + + _tapDownDelegate(TapDownDetails tapDownDetails) { + if (_isDoubleTap) { + _isDoubleTap = false; + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown!(tapDownDetails); + } + } else { + if (widget.onTapDown != null) { + widget.onTapDown!(tapDownDetails); + } + + _isDoubleTap = true; + _doubleTapTimer?.cancel(); + _doubleTapTimer = Timer(kDoubleTapTimeout, () { + _isDoubleTap = false; + _doubleTapTimer = null; + }); + } + } + + @override + void dispose() { + _doubleTapTimer?.cancel(); + super.dispose(); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart index d9920423ae..e36312d7f3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart @@ -1,8 +1,10 @@ +import 'package:flutter/material.dart'; + import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flowy_editor/service/toolbar_service.dart'; +import 'package:flowy_editor/service/scroll_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_editor/service/toolbar_service.dart'; class FlowyService { // selection service @@ -31,10 +33,20 @@ class FlowyService { // toolbar service final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service'); - ToolbarService? get toolbarService { + FlowyToolbarService? get toolbarService { if (toolbarServiceKey.currentState != null && - toolbarServiceKey.currentState is ToolbarService) { - return toolbarServiceKey.currentState! as ToolbarService; + toolbarServiceKey.currentState is FlowyToolbarService) { + return toolbarServiceKey.currentState! as FlowyToolbarService; + } + return null; + } + + // scroll service + final scrollServiceKey = GlobalKey(debugLabel: 'flowy_scroll_service'); + FlowyScrollService? get scrollService { + if (scrollServiceKey.currentState != null && + scrollServiceKey.currentState is FlowyScrollService) { + return scrollServiceKey.currentState! as FlowyScrollService; } return null; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart index feb293aad4..f2026acb23 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart @@ -1,8 +1,9 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/render/selection/toolbar_widget.dart'; import 'package:flutter/material.dart'; -mixin ToolbarService { +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/render/selection/toolbar_widget.dart'; + +mixin FlowyToolbarService { /// Show the toolbar widget beside the offset. void showInOffset(Offset offset, LayerLink layerLink); @@ -24,7 +25,7 @@ class FlowyToolbar extends StatefulWidget { State createState() => _FlowyToolbarState(); } -class _FlowyToolbarState extends State with ToolbarService { +class _FlowyToolbarState extends State with FlowyToolbarService { OverlayEntry? _toolbarOverlay; @override