From d200371002758540eb95934538bf480b7b582940 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Jul 2022 17:56:56 +0800 Subject: [PATCH] feat: add keyboard and cursor --- .../flowy_editor/example/assets/document.json | 6 -- .../lib/plugin/document_node_widget.dart | 7 +- .../example/lib/plugin/image_node_widget.dart | 25 ++++- .../lib/plugin/selected_text_node_widget.dart | 58 ++++++++++- .../flowy_editor/lib/editor_state.dart | 96 ++++++++++++++++--- .../packages/flowy_editor/lib/keyboard.dart | 45 +++++++++ .../flowy_editor/lib/render/selectable.dart | 7 ++ .../lib/render/selection_overlay.dart | 0 8 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/render/selection_overlay.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 c2ba9fbb09..f74672345f 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -3,12 +3,6 @@ "type": "editor", "attributes": {}, "children": [ - { - "type": "image", - "attributes": { - "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w" - } - }, { "type": "text", "delta": [ 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 a9d891cf3d..4608f8b20c 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 @@ -50,7 +50,7 @@ class _EditorNodeWidget extends StatelessWidget { GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (recongizer) { - recongizer..onTap = _onTap; + recongizer..onTapDown = _onTapDown; }, ) }, @@ -73,10 +73,13 @@ class _EditorNodeWidget extends StatelessWidget { ); } - void _onTap() { + void _onTapDown(TapDownDetails details) { editorState.panStartOffset = null; editorState.panEndOffset = null; editorState.updateSelection(); + + editorState.tapOffset = details.globalPosition; + editorState.updateCursor(); } void _onPanStart(DragStartDetails details) { 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 143f5aff01..308477e294 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 @@ -1,5 +1,6 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ @@ -32,7 +33,8 @@ class _ImageNodeWidget extends StatefulWidget { State<_ImageNodeWidget> createState() => __ImageNodeWidgetState(); } -class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { +class __ImageNodeWidgetState extends State<_ImageNodeWidget> + with Selectable, KeyboardEventsRespondable { Node get node => widget.node; EditorState get editorState => widget.editorState; String get src => widget.node.attributes['image_src'] as String; @@ -45,6 +47,27 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { return [boxOffset & size]; } + @override + Rect getCursorRect(Offset start) { + final renderBox = context.findRenderObject() as RenderBox; + final size = Size(5, renderBox.size.height); + final boxOffset = renderBox.localToGlobal(Offset.zero); + final cursorOffset = + Offset(renderBox.size.width + boxOffset.dx, boxOffset.dy); + return cursorOffset & size; + } + + @override + KeyEventResult onKeyDown(RawKeyEvent event) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + TransactionBuilder(editorState) + ..deleteNode(node) + ..commit(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { return _build(context); 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 59b85bb33b..4d04bfb660 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 @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher_string.dart'; class SelectedTextNodeBuilder extends NodeWidgetBuilder { @@ -43,22 +44,24 @@ class _SelectedTextNodeWidget extends StatefulWidget { } class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> - with Selectable { + with Selectable, KeyboardEventsRespondable { TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; final _textKey = GlobalKey(); + TextSelection? _textSelection; RenderParagraph get _renderParagraph => _textKey.currentContext?.findRenderObject() as RenderParagraph; @override List getOverlayRectsInRange(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 // TODO: don't need to compute everytime. - var rects = _computeSelectionRects( - TextSelection(baseOffset: 0, extentOffset: node.toRawString().length), - ); + var rects = _computeSelectionRects(textSelection); + _textSelection = textSelection; if (end.dy > start.dy) { // downward @@ -74,13 +77,44 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> final selectionBaseOffset = _getTextPositionAtOffset(start).offset; final selectionExtentOffset = _getTextPositionAtOffset(end).offset; - final textSelection = TextSelection( + textSelection = TextSelection( baseOffset: selectionBaseOffset, extentOffset: selectionExtentOffset, ); + _textSelection = textSelection; return _computeSelectionRects(textSelection); } + @override + Rect getCursorRect(Offset start) { + final selectionBaseOffset = _getTextPositionAtOffset(start).offset; + final textSelection = TextSelection.collapsed(offset: selectionBaseOffset); + _textSelection = textSelection; + return _computeCursorRect(textSelection.baseOffset); + } + + @override + KeyEventResult onKeyDown(RawKeyEvent event) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + final textSelection = _textSelection; + // TODO: just handle upforward delete. + if (textSelection != null) { + if (textSelection.isCollapsed) { + TransactionBuilder(editorState) + ..deleteText(node, textSelection.start - 1, 1) + ..commit(); + } else { + TransactionBuilder(editorState) + ..deleteText(node, textSelection.start, + textSelection.baseOffset - textSelection.extentOffset) + ..commit(); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { Widget richText; @@ -124,6 +158,20 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> box.toRect().size) .toList(); } + + Rect _computeCursorRect(int offset) { + final position = TextPosition(offset: offset); + var cursorOffset = _renderParagraph.getOffsetForCaret(position, Rect.zero); + cursorOffset = _renderParagraph.localToGlobal(cursorOffset); + final cursorHeight = _renderParagraph.getFullHeightForCaret(position)!; + const cursorWidth = 2; + return Rect.fromLTWH( + cursorOffset.dx - (cursorWidth / 2), + cursorOffset.dy, + cursorWidth.toDouble(), + cursorHeight.toDouble(), + ); + } } extension on TextNode { 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 fce1ac8ffd..9ce17e26de 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,4 +1,7 @@ +import 'dart:collection'; + import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/keyboard.dart'; import 'package:flowy_editor/operation/operation.dart'; import 'package:flowy_editor/render/selectable.dart'; import 'package:flutter/material.dart'; @@ -13,6 +16,7 @@ class EditorState { final StateTree document; final RenderPlugins renderPlugins; + Offset? tapOffset; Offset? panStartOffset; Offset? panEndOffset; @@ -25,11 +29,14 @@ class EditorState { /// TODO: move to a better place. Widget build(BuildContext context) { - return renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: document.root, - editorState: this, + return Keyboard( + editorState: this, + child: renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: document.root, + editorState: this, + ), ), ); } @@ -55,18 +62,45 @@ class EditorState { List selectionOverlays = []; + void updateCursor() { + if (tapOffset == null) { + return; + } + + // TODO: upward and backward + final selectedNode = _calculateSelectedNode(document.root, tapOffset!); + if (selectedNode.isEmpty) { + return; + } + final key = selectedNode.first.key; + if (key != null && key.currentState is Selectable) { + final selectable = key.currentState as Selectable; + final rect = selectable.getCursorRect(tapOffset!); + final overlay = OverlayEntry(builder: ((context) { + return Positioned.fromRect( + rect: rect, + child: Container( + color: Colors.red, + ), + ); + })); + selectionOverlays.add(overlay); + Overlay.of(selectable.context)?.insert(overlay); + } + } + void updateSelection() { selectionOverlays ..forEach((element) => element.remove()) ..clear(); - final selectedNodes = _selectedNodes; - if (selectedNodes.isEmpty) { + final selectedNodes = this.selectedNodes; + if (selectedNodes.isEmpty || + panStartOffset == null || + panEndOffset == null) { return; } - assert(panStartOffset != null && panEndOffset != null); - for (final node in selectedNodes) { final key = node.key; if (key != null && key.currentState is Selectable) { @@ -90,12 +124,46 @@ class EditorState { } } - List get _selectedNodes { - if (panStartOffset == null || panEndOffset == null) { - return []; + List get selectedNodes { + if (panStartOffset != null && panEndOffset != null) { + return _calculateSelectedNodes( + document.root, panStartOffset!, panEndOffset!); } - return _calculateSelectedNodes( - document.root, panStartOffset!, panEndOffset!); + if (tapOffset != null) { + return _calculateSelectedNode(document.root, tapOffset!); + } + return []; + } + + List _calculateSelectedNode(Node node, Offset offset) { + List result = []; + + /// Skip the node without parent because it is the topmost node. + /// Skip the node without key because it cannot get the [RenderObject]. + if (node.parent != null && node.key != null) { + if (_isNodeInOffset(node, offset)) { + result.add(node); + } + } + + /// + for (final child in node.children) { + result.addAll(_calculateSelectedNode(child, offset)); + } + + return result; + } + + bool _isNodeInOffset(Node node, Offset offset) { + assert(node.key != null); + final renderBox = + node.key?.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) { + return false; + } + final boxOffset = renderBox.localToGlobal(Offset.zero); + final boxRect = boxOffset & renderBox.size; + return boxRect.contains(offset); } List _calculateSelectedNodes(Node node, Offset start, Offset end) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart new file mode 100644 index 0000000000..61de2eba90 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart @@ -0,0 +1,45 @@ +import 'package:flutter/services.dart'; + +import '../render/selectable.dart'; +import 'editor_state.dart'; +import 'package:flutter/material.dart'; + +class Keyboard extends StatelessWidget { + final Widget child; + final focusNode = FocusNode(); + final EditorState editorState; + + Keyboard({ + Key? key, + required this.child, + required this.editorState, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: focusNode, + autofocus: true, + onKey: _onKey, + child: child, + ); + } + + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + List result = []; + for (final node in editorState.selectedNodes) { + if (node.key != null && + node.key?.currentState is KeyboardEventsRespondable) { + final respondable = node.key!.currentState as KeyboardEventsRespondable; + result.add(respondable.onKeyDown(event)); + } + } + if (result.contains(KeyEventResult.handled)) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } +} 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 89991bf687..28942835a8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart @@ -5,4 +5,11 @@ mixin Selectable on State { /// Returns a [Rect] list for overlay. /// [start] and [end] are global offsets. List getOverlayRectsInRange(Offset start, Offset end); + + /// Returns a [Offset] for cursor + Rect getCursorRect(Offset start); +} + +mixin KeyboardEventsRespondable on State { + KeyEventResult onKeyDown(RawKeyEvent event); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection_overlay.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection_overlay.dart new file mode 100644 index 0000000000..e69de29bb2