From a831ddc5895ac4c85c2cd120b8adf857b7ca4e49 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 00:16:34 +0800 Subject: [PATCH] refactor: abstract selection and keyboard from editor state --- .../flowy_editor/example/assets/document.json | 117 ++++++++ .../flowy_editor/example/lib/main.dart | 4 +- .../lib/plugin/document_node_widget.dart | 71 +---- .../example/lib/plugin/image_node_widget.dart | 2 +- .../lib/plugin/selected_text_node_widget.dart | 2 +- .../flowy_editor/lib/editor_state.dart | 4 +- .../flowy_editor/lib/flowy_editor.dart | 1 + .../lib/flowy_editor_service.dart | 33 +++ .../lib/flowy_keyboard_service.dart | 70 +++++ .../lib/flowy_selection_service.dart | 279 ++++++++++++++++++ .../packages/flowy_editor/lib/keyboard.dart | 4 +- .../flowy_editor/lib/render/selectable.dart | 4 +- 12 files changed, 524 insertions(+), 67 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index 16635261db..8356e31f7a 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -64,6 +64,123 @@ "heading": "h2" } }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, { "type": "text", "delta": [ 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 83bc2044e0..bdd5658e6b 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -94,7 +94,9 @@ class _MyHomePageState extends State { document: document, renderPlugins: renderPlugins, ); - return _editorState.build(context); + return FlowyEditor( + editorState: _editorState, + ); } }, ), diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart index 4608f8b20c..f9ab3104da 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart @@ -33,66 +33,21 @@ class _EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recongizer) { - recongizer..onTapDown = _onTapDown; - }, - ) - }, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children + .map( + (e) => editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: e, + editorState: editorState, ), - ) - .toList(), - ), + ), + ) + .toList(), ), ); } - - void _onTapDown(TapDownDetails details) { - editorState.panStartOffset = null; - editorState.panEndOffset = null; - editorState.updateSelection(); - - editorState.tapOffset = details.globalPosition; - editorState.updateCursor(); - } - - void _onPanStart(DragStartDetails details) { - editorState.panStartOffset = details.globalPosition; - editorState.updateSelection(); - } - - void _onPanUpdate(DragUpdateDetails details) { - editorState.panEndOffset = details.globalPosition; - editorState.updateSelection(); - } - - void _onPanEnd(DragEndDetails details) { - // do nothing - } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index 308477e294..d5e68bead2 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -40,7 +40,7 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> String get src => widget.node.attributes['image_src'] as String; @override - List getOverlayRectsInRange(Offset start, Offset end) { + List getSelectionRectsInSelection(Offset start, Offset end) { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; final boxOffset = renderBox.localToGlobal(Offset.zero); diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart index 5eb8a1b40f..7c37d69c73 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -56,7 +56,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> _textKey.currentContext?.findRenderObject() as RenderParagraph; @override - List getOverlayRectsInRange(Offset start, Offset end) { + List getSelectionRectsInSelection(Offset start, Offset end) { var textSelection = TextSelection(baseOffset: 0, extentOffset: node.toRawString().length); // Returns select all if the start or end exceeds the size of the box 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 1d3f63a4ba..20c02a031a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -109,8 +109,8 @@ class EditorState { final key = node.key; if (key != null && key.currentState is Selectable) { final selectable = key.currentState as Selectable; - final overlayRects = - selectable.getOverlayRectsInRange(panStartOffset!, panEndOffset!); + final overlayRects = selectable.getSelectionRectsInSelection( + panStartOffset!, panEndOffset!); for (final rect in overlayRects) { // TODO: refactor overlay implement. final overlay = OverlayEntry(builder: ((context) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index f98e1b71b1..96fad4340b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -11,3 +11,4 @@ export 'package:flowy_editor/operation/transaction.dart'; export 'package:flowy_editor/operation/transaction_builder.dart'; export 'package:flowy_editor/operation/operation.dart'; export 'package:flowy_editor/editor_state.dart'; +export 'package:flowy_editor/flowy_editor_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart new file mode 100644 index 0000000000..78dd6809d3 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart @@ -0,0 +1,33 @@ +import 'package:flowy_editor/flowy_keyboard_service.dart'; +import 'package:flowy_editor/flowy_selection_service.dart'; + +import 'editor_state.dart'; +import 'package:flutter/material.dart'; + +class FlowyEditor extends StatefulWidget { + const FlowyEditor({ + Key? key, + required this.editorState, + }) : super(key: key); + + final EditorState editorState; + + @override + State createState() => _FlowyEditorState(); +} + +class _FlowyEditorState extends State { + EditorState get editorState => widget.editorState; + + @override + Widget build(BuildContext context) { + return FlowySelectionWidget( + editorState: editorState, + child: FlowyKeyboardWidget( + handlers: const [], + editorState: editorState, + child: editorState.build(context), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart new file mode 100644 index 0000000000..9593530033 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart @@ -0,0 +1,70 @@ +import 'package:flutter/services.dart'; + +import 'editor_state.dart'; +import 'package:flutter/material.dart'; + +abstract class FlowyKeyboardHandler { + final EditorState editorState; + final RawKeyEvent rawKeyEvent; + + FlowyKeyboardHandler({ + required this.editorState, + required this.rawKeyEvent, + }); + + KeyEventResult onKeyDown(); +} + +/// Process keyboard events +class FlowyKeyboardWidget extends StatefulWidget { + const FlowyKeyboardWidget({ + Key? key, + required this.handlers, + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + final List handlers; + + @override + State createState() => _FlowyKeyboardWidgetState(); +} + +class _FlowyKeyboardWidgetState extends State { + final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: focusNode, + autofocus: true, + onKey: _onKey, + child: widget.child, + ); + } + + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + for (final handler in widget.handlers) { + debugPrint('handle keyboard event $event by $handler'); + + KeyEventResult result = handler.onKeyDown(); + + switch (result) { + case KeyEventResult.handled: + return KeyEventResult.handled; + case KeyEventResult.skipRemainingHandlers: + return KeyEventResult.skipRemainingHandlers; + case KeyEventResult.ignored: + break; + } + } + + return KeyEventResult.ignored; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart new file mode 100644 index 0000000000..77c8474a07 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart @@ -0,0 +1,279 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'editor_state.dart'; +import 'document/node.dart'; +import '../render/selectable.dart'; + +/// Process selection and cursor +mixin _FlowySelectionService on State { + /// [Pan] and [Tap] must be mutually exclusive. + /// Pan + Offset? panStartOffset; + Offset? panEndOffset; + + /// Tap + Offset? tapOffset; + + void updateSelection(); + + void updateCursor(); + + /// Returns selected node(s) + /// Returns empty list if no nodes are being selected. + List get selectedNodes; + + /// Compute selected node triggered by [Tap] + Node? computeSelectedNodeByTap( + Node node, + Offset offset, + ); + + /// Compute selected nodes triggered by [Pan] + List computeSelectedNodesByPan( + Node node, + Offset start, + Offset end, + ); + + /// Pan + bool isNodeInSelection( + Node node, + Offset start, + Offset end, + ); + + /// Tap + bool isNodeInOffset( + Node node, + Offset offset, + ); +} + +class FlowySelectionWidget extends StatefulWidget { + const FlowySelectionWidget({ + Key? key, + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + + @override + State createState() => _FlowySelectionWidgetState(); +} + +class _FlowySelectionWidgetState extends State + with _FlowySelectionService { + List selectionOverlays = []; + + EditorState get editorState => widget.editorState; + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recongizer) { + recongizer.onTapDown = _onTapDown; + }, + ) + }, + child: widget.child, + ); + } + + @override + void updateSelection() { + _clearOverlay(); + + final nodes = selectedNodes; + if (nodes.isEmpty || panStartOffset == null || panEndOffset == null) { + assert(panStartOffset == null); + assert(panEndOffset == null); + return; + } + + for (final node in nodes) { + final selectable = node.key?.currentState as Selectable?; + if (selectable != null) { + final selectionRects = selectable.getSelectionRectsInSelection( + panStartOffset!, panEndOffset!); + for (final rect in selectionRects) { + final overlay = OverlayEntry( + builder: ((context) => Positioned.fromRect( + rect: rect, + child: Container( + color: Colors.yellow.withAlpha(100), + ), + )), + ); + selectionOverlays.add(overlay); + } + } + } + Overlay.of(context)?.insertAll(selectionOverlays); + } + + @override + void updateCursor() { + _clearOverlay(); + + if (tapOffset == null) { + assert(tapOffset == null); + return; + } + + final nodes = selectedNodes; + if (nodes.isEmpty) { + return; + } + + final selectedNode = nodes.first; + final selectable = selectedNode.key?.currentState as Selectable?; + if (selectable != null) { + final rect = selectable.getCursorRect(tapOffset!); + final cursor = OverlayEntry( + builder: ((context) => Positioned.fromRect( + rect: rect, + child: Container( + color: Colors.blue, + ), + )), + ); + selectionOverlays.add(cursor); + } + Overlay.of(context)?.insertAll(selectionOverlays); + } + + @override + List get selectedNodes { + if (panStartOffset != null && panEndOffset != null) { + return computeSelectedNodesByPan( + editorState.document.root, panStartOffset!, panEndOffset!); + } else if (tapOffset != null) { + final reuslt = + computeSelectedNodeByTap(editorState.document.root, tapOffset!); + if (reuslt != null) { + return [reuslt]; + } + } + return []; + } + + @override + Node? computeSelectedNodeByTap(Node node, Offset offset) { + assert(this.tapOffset != null); + final tapOffset = this.tapOffset; + if (tapOffset != null) {} + + if (node.parent != null && node.key != null) { + if (isNodeInOffset(node, offset)) { + return node; + } + } + + for (final child in node.children) { + final result = computeSelectedNodeByTap(child, offset); + if (result != null) { + return result; + } + } + + return null; + } + + @override + List computeSelectedNodesByPan(Node node, Offset start, Offset end) { + List result = []; + if (node.parent != null && node.key != null) { + if (isNodeInSelection(node, start, end)) { + result.add(node); + } + } + for (final child in node.children) { + result.addAll(computeSelectedNodesByPan(child, start, end)); + } + // TODO: sort the result + return result; + } + + @override + bool isNodeInOffset(Node node, Offset offset) { + assert(node.key != null); + final renderBox = + node.key?.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + final boxOffset = renderBox.localToGlobal(Offset.zero); + final boxRect = boxOffset & renderBox.size; + return boxRect.contains(offset); + } + return false; + } + + @override + bool isNodeInSelection(Node node, Offset start, Offset end) { + assert(node.key != null); + final renderBox = + node.key?.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + final rect = Rect.fromPoints(start, end); + final boxOffset = renderBox.localToGlobal(Offset.zero); + final boxRect = boxOffset & renderBox.size; + return rect.overlaps(boxRect); + } + return false; + } + + void _onTapDown(TapDownDetails details) { + debugPrint('on tap down'); + + // TODO: use setter to make them exclusive?? + tapOffset = details.globalPosition; + panStartOffset = null; + panEndOffset = null; + + updateCursor(); + } + + void _onPanStart(DragStartDetails details) { + debugPrint('on pan start'); + + panStartOffset = details.globalPosition; + panEndOffset = null; + tapOffset = null; + } + + void _onPanUpdate(DragUpdateDetails details) { + // debugPrint('on pan update'); + + panEndOffset = details.globalPosition; + tapOffset = null; + + updateSelection(); + } + + void _onPanEnd(DragEndDetails details) { + // do nothing + } + + void _clearOverlay() { + selectionOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart index 61de2eba90..0077b38130 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart @@ -1,7 +1,7 @@ -import 'package:flutter/services.dart'; - import '../render/selectable.dart'; import 'editor_state.dart'; + +import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; class Keyboard extends StatelessWidget { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart index 28942835a8..f040eee98d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; mixin Selectable on State { /// Returns a [Rect] list for overlay. /// [start] and [end] are global offsets. - List getOverlayRectsInRange(Offset start, Offset end); + List getSelectionRectsInSelection(Offset start, Offset end); - /// Returns a [Offset] for cursor + /// Returns a [Rect] for cursor Rect getCursorRect(Offset start); }