From e2f35dd5ccb4c51109684a8255b72b1e3edec361 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Jul 2022 14:55:37 +0800 Subject: [PATCH 01/11] feat: support selection overlay --- .../flowy_editor/example/.vscode/launch.json | 25 ++ .../flowy_editor/example/lib/main.dart | 3 +- .../lib/plugin/debuggable_rich_text.dart | 102 ++++++++ .../lib/plugin/document_node_widget.dart | 57 ++++- .../example/lib/plugin/image_node_widget.dart | 21 +- .../lib/plugin/selected_text_node_widget.dart | 223 ++++++++++++++++++ .../example/lib/plugin/text_node_widget.dart | 3 +- .../text_with_check_box_node_widget.dart | 1 + .../plugin/text_with_heading_node_widget.dart | 5 +- .../flowy_editor/lib/document/node.dart | 2 + .../flowy_editor/lib/editor_state.dart | 84 ++++++- .../flowy_editor/lib/flowy_editor.dart | 2 + .../lib/render/node_widget_builder.dart | 19 +- .../lib/render/render_plugins.dart | 4 + .../flowy_editor/lib/render/selectable.dart | 8 + 15 files changed, 532 insertions(+), 27 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json create mode 100644 frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json b/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json new file mode 100644 index 0000000000..091adbfb6b --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file 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 8a413f78b1..83bc2044e0 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:example/plugin/document_node_widget.dart'; +import 'package:example/plugin/selected_text_node_widget.dart'; import 'package:example/plugin/text_with_heading_node_widget.dart'; import 'package:example/plugin/image_node_widget.dart'; import 'package:example/plugin/text_node_widget.dart'; @@ -65,7 +66,7 @@ class _MyHomePageState extends State { renderPlugins ..register('editor', EditorNodeWidgetBuilder.create) - ..register('text', TextNodeBuilder.create) + ..register('text', SelectedTextNodeBuilder.create) ..register('image', ImageNodeBuilder.create) ..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create) ..register('text/with-heading', TextWithHeadingNodeBuilder.create); diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart new file mode 100644 index 0000000000..6028774ba9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class DebuggableRichText extends StatefulWidget { + final InlineSpan text; + final GlobalKey textKey; + + const DebuggableRichText({ + Key? key, + required this.text, + required this.textKey, + }) : super(key: key); + + @override + State createState() => _DebuggableRichTextState(); +} + +class _DebuggableRichTextState extends State { + final List _textRects = []; + + RenderParagraph get _renderParagraph => + widget.textKey.currentContext?.findRenderObject() as RenderParagraph; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _updateTextRects(); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + CustomPaint( + painter: _BoxPainter( + rects: _textRects, + ), + ), + RichText( + key: widget.textKey, + text: widget.text, + ), + ], + ); + } + + void _updateTextRects() { + setState(() { + _textRects + ..clear() + ..addAll( + _computeLocalSelectionRects( + TextSelection( + baseOffset: 0, + extentOffset: widget.text.toPlainText().length, + ), + ), + ); + }); + } + + List _computeLocalSelectionRects(TextSelection selection) { + final textBoxes = _renderParagraph.getBoxesForSelection(selection); + return textBoxes.map((box) => box.toRect()).toList(); + } +} + +class _BoxPainter extends CustomPainter { + final List _rects; + final Paint _paint; + + _BoxPainter({ + required List rects, + bool fill = false, + }) : _rects = rects, + _paint = Paint() { + _paint.style = fill ? PaintingStyle.fill : PaintingStyle.stroke; + } + + @override + void paint(Canvas canvas, Size size) { + for (final rect in _rects) { + canvas.drawRect( + rect, + _paint + ..color = Color( + (Random().nextDouble() * 0xFFFFFF).toInt(), + ).withOpacity(1.0), + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} 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 2de62948d5..80ca4f5f00 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 @@ -1,15 +1,18 @@ import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class EditorNodeWidgetBuilder extends NodeWidgetBuilder { EditorNodeWidgetBuilder.create({ required super.editorState, required super.node, + required super.key, }) : super.create(); @override Widget build(BuildContext buildContext) { return SingleChildScrollView( + key: key, child: _EditorNodeWidget( node: node, editorState: editorState, @@ -30,21 +33,49 @@ class _EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd; + }, + ), + }, + child: 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 _onPanStart(DragStartDetails details) { + editorState.panStartOffset = details.globalPosition; + } + + 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 692d00baf2..143f5aff01 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 @@ -5,18 +5,20 @@ class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ required super.node, required super.editorState, + required super.key, }) : super.create(); @override Widget build(BuildContext buildContext) { return _ImageNodeWidget( + key: key, node: node, editorState: editorState, ); } } -class _ImageNodeWidget extends StatelessWidget { +class _ImageNodeWidget extends StatefulWidget { final Node node; final EditorState editorState; @@ -26,7 +28,22 @@ class _ImageNodeWidget extends StatelessWidget { required this.editorState, }) : super(key: key); - String get src => node.attributes['image_src'] as String; + @override + State<_ImageNodeWidget> createState() => __ImageNodeWidgetState(); +} + +class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { + Node get node => widget.node; + EditorState get editorState => widget.editorState; + String get src => widget.node.attributes['image_src'] as String; + + @override + List getOverlayRectsInRange(Offset start, Offset end) { + final renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final boxOffset = renderBox.localToGlobal(Offset.zero); + return [boxOffset & size]; + } @override Widget build(BuildContext 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 new file mode 100644 index 0000000000..59b85bb33b --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -0,0 +1,223 @@ +import 'package:example/plugin/debuggable_rich_text.dart'; +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class SelectedTextNodeBuilder extends NodeWidgetBuilder { + SelectedTextNodeBuilder.create({ + required super.node, + required super.editorState, + required super.key, + }) : super.create() { + nodeValidator = ((node) { + return node.type == 'text'; + }); + } + + @override + Widget build(BuildContext buildContext) { + return _SelectedTextNodeWidget( + key: key, + node: node, + editorState: editorState, + ); + } +} + +class _SelectedTextNodeWidget extends StatefulWidget { + final Node node; + final EditorState editorState; + + const _SelectedTextNodeWidget({ + Key? key, + required this.node, + required this.editorState, + }) : super(key: key); + + @override + State<_SelectedTextNodeWidget> createState() => + _SelectedTextNodeWidgetState(); +} + +class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> + with Selectable { + TextNode get node => widget.node as TextNode; + EditorState get editorState => widget.editorState; + + final _textKey = GlobalKey(); + + RenderParagraph get _renderParagraph => + _textKey.currentContext?.findRenderObject() as RenderParagraph; + + @override + List getOverlayRectsInRange(Offset start, Offset end) { + // 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), + ); + + if (end.dy > start.dy) { + // downward + if (end.dy >= rects.last.bottom) { + return rects; + } + } else { + // upward + if (end.dy <= rects.first.top) { + return rects; + } + } + + final selectionBaseOffset = _getTextPositionAtOffset(start).offset; + final selectionExtentOffset = _getTextPositionAtOffset(end).offset; + final textSelection = TextSelection( + baseOffset: selectionBaseOffset, + extentOffset: selectionExtentOffset, + ); + return _computeSelectionRects(textSelection); + } + + @override + Widget build(BuildContext context) { + Widget richText; + if (kDebugMode) { + richText = DebuggableRichText(text: node.toTextSpan(), textKey: _textKey); + } else { + richText = RichText(key: _textKey, text: node.toTextSpan()); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + richText, + if (node.children.isNotEmpty) + ...node.children.map( + (e) => editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: e, + editorState: editorState, + ), + ), + ), + const SizedBox( + height: 5, + ), + ], + ); + } + + TextPosition _getTextPositionAtOffset(Offset offset) { + final textOffset = _renderParagraph.globalToLocal(offset); + return _renderParagraph.getPositionForOffset(textOffset); + } + + List _computeSelectionRects(TextSelection selection) { + final textBoxes = _renderParagraph.getBoxesForSelection(selection); + return textBoxes + .map((box) => + _renderParagraph.localToGlobal(box.toRect().topLeft) & + box.toRect().size) + .toList(); + } +} + +extension on TextNode { + TextSpan toTextSpan() => TextSpan( + children: delta.operations + .whereType() + .map((op) => op.toTextSpan()) + .toList()); +} + +extension on TextInsert { + TextSpan toTextSpan() { + FontWeight? fontWeight; + FontStyle? fontStyle; + TextDecoration? decoration; + GestureRecognizer? gestureRecognizer; + Color color = Colors.black; + Color highLightColor = Colors.transparent; + double fontSize = 16.0; + final attributes = this.attributes; + if (attributes?['bold'] == true) { + fontWeight = FontWeight.bold; + } + if (attributes?['italic'] == true) { + fontStyle = FontStyle.italic; + } + if (attributes?['underline'] == true) { + decoration = TextDecoration.underline; + } + if (attributes?['strikethrough'] == true) { + decoration = TextDecoration.lineThrough; + } + if (attributes?['highlight'] is String) { + highLightColor = Color(int.parse(attributes!['highlight'])); + } + if (attributes?['href'] is String) { + color = const Color.fromARGB(255, 55, 120, 245); + decoration = TextDecoration.underline; + gestureRecognizer = TapGestureRecognizer() + ..onTap = () { + launchUrlString(attributes?['href']); + }; + } + final heading = attributes?['heading'] as String?; + if (heading != null) { + // TODO: make it better + if (heading == 'h1') { + fontSize = 30.0; + } else if (heading == 'h2') { + fontSize = 20.0; + } + fontWeight = FontWeight.bold; + } + return TextSpan( + text: content, + style: TextStyle( + fontWeight: fontWeight, + fontStyle: fontStyle, + decoration: decoration, + color: color, + fontSize: fontSize, + backgroundColor: highLightColor, + ), + recognizer: gestureRecognizer, + ); + } +} + +class FlowyPainter extends CustomPainter { + final List _rects; + final Paint _paint; + + FlowyPainter({ + Key? key, + required Color color, + required List rects, + bool fill = false, + }) : _rects = rects, + _paint = Paint()..color = color { + _paint.style = fill ? PaintingStyle.fill : PaintingStyle.stroke; + } + + @override + void paint(Canvas canvas, Size size) { + for (final rect in _rects) { + canvas.drawRect( + rect, + _paint, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index 0077707fe8..911a6179be 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -12,6 +12,7 @@ class TextNodeBuilder extends NodeWidgetBuilder { TextNodeBuilder.create({ required super.node, required super.editorState, + required super.key, }) : super.create() { nodeValidator = ((node) { return node.type == 'text'; @@ -20,7 +21,7 @@ class TextNodeBuilder extends NodeWidgetBuilder { @override Widget build(BuildContext buildContext) { - return _TextNodeWidget(node: node, editorState: editorState); + return _TextNodeWidget(key: key, node: node, editorState: editorState); } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart index 37a30fb6be..ff6c6e9932 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart @@ -5,6 +5,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { TextWithCheckBoxNodeBuilder.create({ required super.node, required super.editorState, + required super.key, }) : super.create(); // TODO: check the type diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart index 9519e130f2..22022a65ec 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart @@ -5,6 +5,7 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder { TextWithHeadingNodeBuilder.create({ required super.editorState, required super.node, + required super.key, }) : super.create() { nodeValidator = (node) => node.attributes.containsKey('heading'); } @@ -15,9 +16,9 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder { return const Padding( padding: EdgeInsets.only(top: 10), ); - } else if (heading == 'h1') { + } else if (heading == 'h2') { return const Padding( - padding: EdgeInsets.only(top: 10), + padding: EdgeInsets.only(top: 5), ); } return const Padding( diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 58f32d31c0..8c75eca360 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -10,6 +10,8 @@ class Node extends ChangeNotifier with LinkedListEntry { final LinkedList children; final Attributes attributes; + GlobalKey? key; + String? get subtype { // TODO: make 'subtype' as a const value. if (attributes.containsKey('subtype')) { 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 521231a495..bc111ab4b8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,6 +1,6 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/document/attributes.dart'; +import 'package:flowy_editor/render/selectable.dart'; import 'package:flutter/material.dart'; import './document/state_tree.dart'; @@ -12,6 +12,10 @@ import './render/render_plugins.dart'; class EditorState { final StateTree document; final RenderPlugins renderPlugins; + + Offset? panStartOffset; + Offset? panEndOffset; + Selection? cursorSelection; EditorState({ @@ -48,4 +52,82 @@ class EditorState { document.textEdit(op.path, op.delta); } } + + List selectionOverlays = []; + + void updateSelection() { + final selectedNodes = _selectedNodes; + if (selectedNodes.isEmpty) { + return; + } + + assert(panStartOffset != null && panEndOffset != null); + + selectionOverlays + ..forEach((element) => element.remove()) + ..clear(); + for (final node in selectedNodes) { + final key = node.key; + if (key != null && key.currentState is Selectable) { + final selectable = key.currentState as Selectable; + final overlayRects = + selectable.getOverlayRectsInRange(panStartOffset!, panEndOffset!); + for (final rect in overlayRects) { + // TODO: refactor overlay implement. + final overlay = OverlayEntry(builder: ((context) { + return Positioned.fromRect( + rect: rect, + child: Container( + color: Colors.yellow.withAlpha(100), + ), + ); + })); + selectionOverlays.add(overlay); + Overlay.of(selectable.context)?.insert(overlay); + } + } + } + } + + List get _selectedNodes { + if (panStartOffset == null || panEndOffset == null) { + return []; + } + return _calculateSelectedNodes( + document.root, panStartOffset!, panEndOffset!); + } + + List _calculateSelectedNodes(Node node, Offset start, Offset end) { + 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 (_isNodeInRange(node, start, end)) { + result.add(node); + } + } + + /// + for (final child in node.children) { + result.addAll(_calculateSelectedNodes(child, start, end)); + } + + return result; + } + + bool _isNodeInRange(Node node, Offset start, Offset end) { + assert(node.key != null); + final renderBox = + node.key?.currentContext?.findRenderObject() as RenderBox?; + + /// Return false directly if the [RenderBox] cannot found. + if (renderBox == null) { + return false; + } + + final rect = Rect.fromPoints(start, end); + final boxOffset = renderBox.localToGlobal(Offset.zero); + return rect.overlaps(boxOffset & renderBox.size); + } } 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 f816778603..f98e1b71b1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -3,8 +3,10 @@ library flowy_editor; export 'package:flowy_editor/document/state_tree.dart'; export 'package:flowy_editor/document/node.dart'; export 'package:flowy_editor/document/path.dart'; +export 'package:flowy_editor/document/text_delta.dart'; export 'package:flowy_editor/render/render_plugins.dart'; export 'package:flowy_editor/render/node_widget_builder.dart'; +export 'package:flowy_editor/render/selectable.dart'; export 'package:flowy_editor/operation/transaction.dart'; export 'package:flowy_editor/operation/transaction_builder.dart'; export 'package:flowy_editor/operation/operation.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart index badce60694..f349a0fe3d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart @@ -9,6 +9,7 @@ typedef NodeValidator = bool Function(T node); class NodeWidgetBuilder { final EditorState editorState; final T node; + final Key key; bool rebuildOnNodeChanged; NodeValidator? nodeValidator; @@ -18,14 +19,22 @@ class NodeWidgetBuilder { NodeWidgetBuilder.create({ required this.editorState, required this.node, + required this.key, this.rebuildOnNodeChanged = true, }); /// Render the current [Node] /// and the layout style of [Node.Children]. - Widget build(BuildContext buildContext) => throw UnimplementedError(); + Widget build( + BuildContext buildContext, + ) => + throw UnimplementedError(); - Widget call(BuildContext buildContext) { + /// TODO: refactore this part. + /// return widget embeded with ChangeNotifier and widget itself. + Widget call( + BuildContext buildContext, + ) { /// TODO: Validate the node /// if failed, stop call build function, /// return Empty widget, and throw Error. @@ -34,11 +43,7 @@ class NodeWidgetBuilder { 'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }'); } - if (rebuildOnNodeChanged) { - return _buildNodeChangeNotifier(buildContext); - } else { - return build(buildContext); - } + return _buildNodeChangeNotifier(buildContext); } Widget _buildNodeChangeNotifier(BuildContext buildContext) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart index a9bbd8b070..efe5865d64 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart @@ -19,6 +19,7 @@ typedef NodeWidgetBuilderF = A Function({ required T node, required EditorState editorState, + required GlobalKey key, }); // unused @@ -63,9 +64,12 @@ class RenderPlugins { name += '/${node.subtype}'; } final nodeWidgetBuilder = _nodeWidgetBuilder(name); + final key = GlobalKey(); + node.key = key; return nodeWidgetBuilder( node: context.node, editorState: context.editorState, + key: key, )(context.buildContext); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart new file mode 100644 index 0000000000..89991bf687 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart @@ -0,0 +1,8 @@ +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); +} From e3e1d254946a4eb47819ba540b5dc81ed4255753 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Jul 2022 15:21:19 +0800 Subject: [PATCH 02/11] feat: hide overlay when tap on the editor --- .../example/lib/plugin/document_node_widget.dart | 14 ++++++++++++++ .../packages/flowy_editor/lib/editor_state.dart | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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 80ca4f5f00..a9d891cf3d 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 @@ -46,6 +46,13 @@ class _EditorNodeWidget extends StatelessWidget { ..onEnd = _onPanEnd; }, ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recongizer) { + recongizer..onTap = _onTap; + }, + ) }, child: SingleChildScrollView( child: Column( @@ -66,8 +73,15 @@ class _EditorNodeWidget extends StatelessWidget { ); } + void _onTap() { + editorState.panStartOffset = null; + editorState.panEndOffset = null; + editorState.updateSelection(); + } + void _onPanStart(DragStartDetails details) { editorState.panStartOffset = details.globalPosition; + editorState.updateSelection(); } void _onPanUpdate(DragUpdateDetails details) { 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 bc111ab4b8..fce1ac8ffd 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -56,6 +56,10 @@ class EditorState { List selectionOverlays = []; void updateSelection() { + selectionOverlays + ..forEach((element) => element.remove()) + ..clear(); + final selectedNodes = _selectedNodes; if (selectedNodes.isEmpty) { return; @@ -63,9 +67,6 @@ class EditorState { assert(panStartOffset != null && panEndOffset != null); - selectionOverlays - ..forEach((element) => element.remove()) - ..clear(); for (final node in selectedNodes) { final key = node.key; if (key != null && key.currentState is Selectable) { From d200371002758540eb95934538bf480b7b582940 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Jul 2022 17:56:56 +0800 Subject: [PATCH 03/11] 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 From eb97141859594b789da08a2d5140659c9e30ff81 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 21 Jul 2022 20:04:25 +0800 Subject: [PATCH 04/11] feat: remove subtype render --- .../flowy_editor/example/assets/document.json | 143 +++++++++--------- .../lib/plugin/selected_text_node_widget.dart | 44 ++++-- .../example/lib/plugin/text_node_widget.dart | 10 +- .../flowy_editor/lib/document/node.dart | 6 +- .../flowy_editor/lib/editor_state.dart | 4 + .../lib/render/render_plugins.dart | 1 + 6 files changed, 116 insertions(+), 92 deletions(-) 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 f74672345f..16635261db 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -1,71 +1,78 @@ { - "document": { - "type": "editor", - "attributes": {}, - "children": [ - { - "type": "text", - "delta": [ - { "insert": "👋 Welcome to AppFlowy!", "attributes": { "href": "https://www.appflowy.io/", "heading": "h1" } } - ], - "attributes": { - "subtype": "with-heading", - "heading": "h1" + "document": { + "type": "editor", + "attributes": {}, + "children": [ + { + "type": "text", + "delta": [ + { + "insert": "👋 Welcome to AppFlowy!", + "attributes": { + "href": "https://www.appflowy.io/", + "heading": "h1" + } } - }, - { - "type": "text", - "delta": [ - { "insert": "Here are the basics", "attributes": { "heading": "h2" } } - ], - "attributes": { - "subtype": "with-heading", - "heading": "h2" - } - }, - { - "type": "text", - "delta": [ - { "insert": "Click anywhere and just start typing." } - ], - "attributes": { - "subtype": "with-checkbox", - "checkbox": true - } - }, - { - "type": "text", - "delta": [ - { "insert": "Highlight", "attributes": { "highlight": "0xFFFFFF00" } }, - { "insert": " Click anywhere and just start typing" }, - { "insert": " any text, and use the menu at the bottom to " }, - { "insert": "style", "attributes": { "italic": true } }, - { "insert": " your ", "attributes": { "bold": true } }, - { "insert": "writing", "attributes": { "underline": true } }, - { "insert": " howeverv you like.", "attributes": { "strikethrough": true } } - ], - "attributes": { - "subtype": "with-checkbox", - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { "insert": "Have a question? ", "attributes": { "heading": "h2" } } - ], - "attributes": { - "subtype": "with-heading", - "heading": "h2" - } - }, - { - "type": "text", - "delta": [ - { "insert": "Click the '?' at the bottom right for help and support."} - ], - "attributes": {} + ], + "attributes": { + "heading": "h1" } - ] - } - } \ No newline at end of file + }, + { + "type": "text", + "delta": [ + { "insert": "Here are the basics", "attributes": { "heading": "h2" } } + ], + "attributes": { + "heading": "h2" + } + }, + { + "type": "text", + "delta": [{ "insert": "Click anywhere and just start typing." }], + "attributes": { + "checkbox": true + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Highlight", + "attributes": { "highlight": "0xFFFFFF00" } + }, + { "insert": " Click anywhere and just start typing" }, + { "insert": " any text, and use the menu at the bottom to " }, + { "insert": "style", "attributes": { "italic": true } }, + { "insert": " your ", "attributes": { "bold": true } }, + { "insert": "writing", "attributes": { "underline": true } }, + { + "insert": " howeverv you like.", + "attributes": { "strikethrough": true } + } + ], + "attributes": { + "checkbox": false + } + }, + { + "type": "text", + "delta": [ + { "insert": "Have a question? ", "attributes": { "heading": "h2" } } + ], + "attributes": { + "heading": "h2" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + } + ] + } +} 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 4d04bfb660..5eb8a1b40f 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 @@ -20,6 +20,7 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder { @override Widget build(BuildContext buildContext) { + print('key -> $key'); return _SelectedTextNodeWidget( key: key, node: node, @@ -100,9 +101,20 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> // TODO: just handle upforward delete. if (textSelection != null) { if (textSelection.isCollapsed) { - TransactionBuilder(editorState) - ..deleteText(node, textSelection.start - 1, 1) - ..commit(); + print(node.toRawString()); + print('is empty ${node.toRawString().isEmpty}'); + if (textSelection.baseOffset == 0 && node.toRawString().isEmpty) { + TransactionBuilder(editorState) + ..deleteNode(node) + ..commit(); + } else { + TransactionBuilder(editorState) + ..deleteText(node, textSelection.start - 1, 1) + ..commit(); + final rect = _computeCursorRect(textSelection.baseOffset - 1); + editorState.tapOffset = rect.center; + editorState.updateCursor(); + } } else { TransactionBuilder(editorState) ..deleteText(node, textSelection.start, @@ -117,6 +129,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> @override Widget build(BuildContext context) { + print('text rebuild $this'); Widget richText; if (kDebugMode) { richText = DebuggableRichText(text: node.toTextSpan(), textKey: _textKey); @@ -127,7 +140,10 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - richText, + SizedBox( + width: MediaQuery.of(context).size.width, + child: richText, + ), if (node.children.isNotEmpty) ...node.children.map( (e) => editorState.renderPlugins.buildWidget( @@ -163,14 +179,18 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> 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(), - ); + final cursorHeight = _renderParagraph.getFullHeightForCaret(position); + if (cursorHeight != null) { + const cursorWidth = 2; + return Rect.fromLTWH( + cursorOffset.dx - (cursorWidth / 2), + cursorOffset.dy, + cursorWidth.toDouble(), + cursorHeight.toDouble(), + ); + } else { + return Rect.zero; + } } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index 911a6179be..c265dc6254 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -103,7 +103,6 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> textCapitalization: TextCapitalization.sentences, ), ); - debugPrint('selection: $selection'); editorState.cursorSelection = _localSelectionToGlobal(node, selection); _textInputConnection ?..show() @@ -182,9 +181,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> } @override - void performAction(TextInputAction action) { - debugPrint('action:$action'); - } + void performAction(TextInputAction action) {} @override void performPrivateCommand(String action, Map data) { @@ -207,13 +204,10 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> } @override - void updateEditingValue(TextEditingValue value) { - debugPrint('offset: ${value.selection}'); - } + void updateEditingValue(TextEditingValue value) {} @override void updateEditingValueWithDeltas(List textEditingDeltas) { - debugPrint(textEditingDeltas.toString()); for (final textDelta in textEditingDeltas) { if (textDelta is TextEditingDeltaInsertion) { TransactionBuilder(editorState) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 8c75eca360..8010a40646 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -186,8 +186,6 @@ class TextNode extends Node { return map; } - String toRawString() => _delta.operations - .whereType() - .map((op) => op.content) - .toString(); + String toRawString() => + _delta.operations.whereType().map((op) => op.content).join(); } 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 9ce17e26de..1d3f63a4ba 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -63,6 +63,10 @@ class EditorState { List selectionOverlays = []; void updateCursor() { + selectionOverlays + ..forEach((element) => element.remove()) + ..clear(); + if (tapOffset == null) { return; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart index efe5865d64..5d62c7b246 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart @@ -51,6 +51,7 @@ class RenderPlugins { _nodeWidgetBuilders.removeWhere((key, _) => key == name); } + @protected Widget buildWidget({ required NodeWidgetContext context, bool withSubtype = true, From a831ddc5895ac4c85c2cd120b8adf857b7ca4e49 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 00:16:34 +0800 Subject: [PATCH 05/11] 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); } From c643c02887d0b28fb634798c865717f550a2c5ac Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 00:46:25 +0800 Subject: [PATCH 06/11] feat: add keyboard example --- .../lib/plugin/document_node_widget.dart | 1 - .../example/lib/plugin/image_node_widget.dart | 5 + .../lib/plugin/selected_text_node_widget.dart | 11 +- .../flowy_editor/lib/editor_state.dart | 149 +----------------- .../lib/flowy_editor_service.dart | 6 +- .../lib/flowy_keyboard_service.dart | 44 +++++- .../lib/flowy_selection_service.dart | 2 + .../packages/flowy_editor/lib/keyboard.dart | 28 ++-- .../flowy_editor/lib/render/selectable.dart | 5 +- 9 files changed, 79 insertions(+), 172 deletions(-) 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 f9ab3104da..2db1ef89c4 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 @@ -1,5 +1,4 @@ import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class EditorNodeWidgetBuilder extends NodeWidgetBuilder { 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 d5e68bead2..f1719db744 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 @@ -57,6 +57,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> return cursorOffset & size; } + @override + TextSelection? getTextSelection() { + return null; + } + @override KeyEventResult onKeyDown(RawKeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.backspace) { 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 7c37d69c73..356a21e4f2 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 @@ -94,6 +94,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> return _computeCursorRect(textSelection.baseOffset); } + @override + TextSelection? getTextSelection() { + return _textSelection; + } + @override KeyEventResult onKeyDown(RawKeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.backspace) { @@ -111,9 +116,9 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> TransactionBuilder(editorState) ..deleteText(node, textSelection.start - 1, 1) ..commit(); - final rect = _computeCursorRect(textSelection.baseOffset - 1); - editorState.tapOffset = rect.center; - editorState.updateCursor(); + // final rect = _computeCursorRect(textSelection.baseOffset - 1); + // editorState.tapOffset = rect.center; + // editorState.updateCursor(); } } else { TransactionBuilder(editorState) 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 20c02a031a..ea9b5bfee1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -15,10 +15,7 @@ import './render/render_plugins.dart'; class EditorState { final StateTree document; final RenderPlugins renderPlugins; - - Offset? tapOffset; - Offset? panStartOffset; - Offset? panEndOffset; + List selectedNodes = []; Selection? cursorSelection; @@ -59,148 +56,4 @@ class EditorState { document.textEdit(op.path, op.delta); } } - - List selectionOverlays = []; - - void updateCursor() { - selectionOverlays - ..forEach((element) => element.remove()) - ..clear(); - - 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 = this.selectedNodes; - if (selectedNodes.isEmpty || - panStartOffset == null || - panEndOffset == null) { - return; - } - - for (final node in selectedNodes) { - final key = node.key; - if (key != null && key.currentState is Selectable) { - final selectable = key.currentState as Selectable; - final overlayRects = selectable.getSelectionRectsInSelection( - panStartOffset!, panEndOffset!); - for (final rect in overlayRects) { - // TODO: refactor overlay implement. - final overlay = OverlayEntry(builder: ((context) { - return Positioned.fromRect( - rect: rect, - child: Container( - color: Colors.yellow.withAlpha(100), - ), - ); - })); - selectionOverlays.add(overlay); - Overlay.of(selectable.context)?.insert(overlay); - } - } - } - } - - List get selectedNodes { - if (panStartOffset != null && panEndOffset != null) { - 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) { - 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 (_isNodeInRange(node, start, end)) { - result.add(node); - } - } - - /// - for (final child in node.children) { - result.addAll(_calculateSelectedNodes(child, start, end)); - } - - return result; - } - - bool _isNodeInRange(Node node, Offset start, Offset end) { - assert(node.key != null); - final renderBox = - node.key?.currentContext?.findRenderObject() as RenderBox?; - - /// Return false directly if the [RenderBox] cannot found. - if (renderBox == null) { - return false; - } - - final rect = Rect.fromPoints(start, end); - final boxOffset = renderBox.localToGlobal(Offset.zero); - return rect.overlaps(boxOffset & renderBox.size); - } } 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 index 78dd6809d3..01b4fdf419 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart @@ -24,7 +24,11 @@ class _FlowyEditorState extends State { return FlowySelectionWidget( editorState: editorState, child: FlowyKeyboardWidget( - handlers: const [], + handlers: [ + FlowyKeyboradBackSpaceHandler( + editorState: editorState, + ) + ], 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 index 9593530033..c7752abae5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart @@ -1,3 +1,7 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/operation/transaction.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/selectable.dart'; import 'package:flutter/services.dart'; import 'editor_state.dart'; @@ -5,14 +9,44 @@ import 'package:flutter/material.dart'; abstract class FlowyKeyboardHandler { final EditorState editorState; - final RawKeyEvent rawKeyEvent; FlowyKeyboardHandler({ required this.editorState, - required this.rawKeyEvent, }); - KeyEventResult onKeyDown(); + KeyEventResult onKeyDown(RawKeyEvent event); +} + +class FlowyKeyboradBackSpaceHandler extends FlowyKeyboardHandler { + FlowyKeyboradBackSpaceHandler({ + required super.editorState, + }); + + @override + KeyEventResult onKeyDown(RawKeyEvent event) { + final selectedNodes = editorState.selectedNodes; + if (selectedNodes.isNotEmpty) { + // handle delete text + // TODO: type: cursor or selection + if (selectedNodes.length == 1) { + final node = selectedNodes.first; + if (node is TextNode) { + final selectable = node.key?.currentState as Selectable?; + final textSelection = selectable?.getTextSelection(); + if (textSelection != null) { + if (textSelection.isCollapsed) { + TransactionBuilder(editorState) + ..deleteText(node, textSelection.start - 1, 1) + ..commit(); + // TODO: update selection?? + } + } + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } } /// Process keyboard events @@ -46,6 +80,8 @@ class _FlowyKeyboardWidgetState extends State { } KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + debugPrint('on keyboard event $event'); + if (event is! RawKeyDownEvent) { return KeyEventResult.ignored; } @@ -53,7 +89,7 @@ class _FlowyKeyboardWidgetState extends State { for (final handler in widget.handlers) { debugPrint('handle keyboard event $event by $handler'); - KeyEventResult result = handler.onKeyDown(); + KeyEventResult result = handler.onKeyDown(event); switch (result) { case KeyEventResult.handled: 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 index 77c8474a07..6c55d6f955 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart @@ -102,6 +102,7 @@ class _FlowySelectionWidgetState extends State _clearOverlay(); final nodes = selectedNodes; + editorState.selectedNodes = nodes; if (nodes.isEmpty || panStartOffset == null || panEndOffset == null) { assert(panStartOffset == null); assert(panEndOffset == null); @@ -139,6 +140,7 @@ class _FlowySelectionWidgetState extends State } final nodes = selectedNodes; + editorState.selectedNodes = nodes; if (nodes.isEmpty) { return; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart index 0077b38130..4cb39ce31e 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart @@ -26,20 +26,20 @@ class Keyboard extends StatelessWidget { } 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; - } + // 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 f040eee98d..8d1951996d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart @@ -6,8 +6,11 @@ mixin Selectable on State { /// [start] and [end] are global offsets. List getSelectionRectsInSelection(Offset start, Offset end); - /// Returns a [Rect] for cursor + /// Returns a [Rect] for cursor. Rect getCursorRect(Offset start); + + /// For [TextNode] only. + TextSelection? getTextSelection(); } mixin KeyboardEventsRespondable on State { From 34a1da8450573483a1f8e3ac7b416eb8479f368d Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 09:44:20 +0800 Subject: [PATCH 07/11] chore: delete unused code --- .../example/lib/plugin/image_node_widget.dart | 14 +----- .../lib/plugin/selected_text_node_widget.dart | 37 +-------------- .../flowy_editor/lib/editor_state.dart | 17 +++---- .../packages/flowy_editor/lib/keyboard.dart | 45 ------------------- .../lib/render/render_plugins.dart | 1 - .../flowy_editor/lib/render/selectable.dart | 4 -- .../lib/render/selection_overlay.dart | 0 7 files changed, 7 insertions(+), 111 deletions(-) delete mode 100644 frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart delete mode 100644 frontend/app_flowy/packages/flowy_editor/lib/render/selection_overlay.dart 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 f1719db744..b235f8f481 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 @@ -33,8 +33,7 @@ class _ImageNodeWidget extends StatefulWidget { State<_ImageNodeWidget> createState() => __ImageNodeWidgetState(); } -class __ImageNodeWidgetState extends State<_ImageNodeWidget> - with Selectable, KeyboardEventsRespondable { +class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { Node get node => widget.node; EditorState get editorState => widget.editorState; String get src => widget.node.attributes['image_src'] as String; @@ -62,17 +61,6 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> return null; } - @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 356a21e4f2..7ce7162b07 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,7 +4,6 @@ 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 { @@ -20,7 +19,6 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder { @override Widget build(BuildContext buildContext) { - print('key -> $key'); return _SelectedTextNodeWidget( key: key, node: node, @@ -45,7 +43,7 @@ class _SelectedTextNodeWidget extends StatefulWidget { } class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> - with Selectable, KeyboardEventsRespondable { + with Selectable { TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; @@ -99,39 +97,6 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> return _textSelection; } - @override - KeyEventResult onKeyDown(RawKeyEvent event) { - if (event.logicalKey == LogicalKeyboardKey.backspace) { - final textSelection = _textSelection; - // TODO: just handle upforward delete. - if (textSelection != null) { - if (textSelection.isCollapsed) { - print(node.toRawString()); - print('is empty ${node.toRawString().isEmpty}'); - if (textSelection.baseOffset == 0 && node.toRawString().isEmpty) { - TransactionBuilder(editorState) - ..deleteNode(node) - ..commit(); - } else { - TransactionBuilder(editorState) - ..deleteText(node, textSelection.start - 1, 1) - ..commit(); - // final rect = _computeCursorRect(textSelection.baseOffset - 1); - // editorState.tapOffset = rect.center; - // editorState.updateCursor(); - } - } else { - TransactionBuilder(editorState) - ..deleteText(node, textSelection.start, - textSelection.baseOffset - textSelection.extentOffset) - ..commit(); - } - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { print('text rebuild $this'); 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 ea9b5bfee1..ed917403b6 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,9 +1,5 @@ -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'; import './document/state_tree.dart'; @@ -26,14 +22,11 @@ class EditorState { /// TODO: move to a better place. Widget build(BuildContext context) { - return Keyboard( - editorState: this, - child: renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: document.root, - editorState: this, - ), + return renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: document.root, + editorState: this, ), ); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart b/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart deleted file mode 100644 index 4cb39ce31e..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart +++ /dev/null @@ -1,45 +0,0 @@ -import '../render/selectable.dart'; -import 'editor_state.dart'; - -import 'package:flutter/services.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/render_plugins.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart index 5d62c7b246..efe5865d64 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart @@ -51,7 +51,6 @@ class RenderPlugins { _nodeWidgetBuilders.removeWhere((key, _) => key == name); } - @protected Widget buildWidget({ required NodeWidgetContext context, bool withSubtype = true, 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 8d1951996d..3631da106f 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart @@ -12,7 +12,3 @@ mixin Selectable on State { /// For [TextNode] only. TextSelection? getTextSelection(); } - -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 deleted file mode 100644 index e69de29bb2..0000000000 From e16444f88e7ac956bdeee70e074e4febdd49509d Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 10:43:20 +0800 Subject: [PATCH 08/11] fix: make sure the state is implemnt selectable, otherwise return. --- .../lib/flowy_selection_service.dart | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) 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 index 6c55d6f955..b2a9f2b9c8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart @@ -110,21 +110,22 @@ class _FlowySelectionWidgetState extends State } 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); - } + if (node.key?.currentState is! Selectable) { + continue; + } + final selectable = node.key?.currentState as Selectable; + 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); @@ -146,19 +147,20 @@ class _FlowySelectionWidgetState extends State } 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); + if (selectedNode.key?.currentState is! Selectable) { + return; } + final selectable = selectedNode.key?.currentState as Selectable; + 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); } @@ -183,12 +185,6 @@ class _FlowySelectionWidgetState extends State 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) { @@ -196,6 +192,12 @@ class _FlowySelectionWidgetState extends State } } + if (node.parent != null && node.key != null) { + if (isNodeInOffset(node, offset)) { + return node; + } + } + return null; } From a6ede7dc7532246c3c99066792df45ddad89e0a6 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Fri, 22 Jul 2022 15:45:57 +0800 Subject: [PATCH 09/11] feat: add a floating cursor and follow the document scroll. refactor the keyboard handler to a Function. --- .../flowy_editor/example/assets/document.json | 6 ++ .../flowy_editor/example/lib/main.dart | 3 + .../example/lib/plugin/image_node_widget.dart | 13 +++- .../flowy_editor/lib/document/node.dart | 3 + .../flowy_editor/lib/flowy_cursor_widget.dart | 60 +++++++++++++++++ .../flowy_editor/lib/flowy_editor.dart | 1 + .../lib/flowy_editor_service.dart | 7 +- .../lib/flowy_keyboard_service.dart | 66 +++++++------------ .../lib/flowy_selection_service.dart | 38 +++++------ .../lib/render/node_widget_builder.dart | 5 +- 10 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.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 8356e31f7a..e89a258206 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -3,6 +3,12 @@ "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/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index bdd5658e6b..1e047a23b4 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -96,6 +96,9 @@ class _MyHomePageState extends State { ); return FlowyEditor( editorState: _editorState, + keyEventHandler: [ + deleteSingleImageNode, + ], ); } }, 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 b235f8f481..934974ce8c 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,6 +1,17 @@ import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/flowy_keyboard_service.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; + +FlowyKeyEventHandler deleteSingleImageNode = (editorState, event) { + final selectNodes = editorState.selectedNodes; + if (selectNodes.length != 1 || selectNodes.first.type != 'image') { + return KeyEventResult.ignored; + } + TransactionBuilder(editorState) + ..deleteNode(selectNodes.first) + ..commit(); + return KeyEventResult.handled; +}; class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 8010a40646..8b80fd0b51 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -11,6 +11,8 @@ class Node extends ChangeNotifier with LinkedListEntry { final Attributes attributes; GlobalKey? key; + // TODO: abstract a selectable node?? + final layerLink = LayerLink(); String? get subtype { // TODO: make 'subtype' as a const value. @@ -186,6 +188,7 @@ class TextNode extends Node { return map; } + // TODO: It's unneccesry to compute everytime. String toRawString() => _delta.operations.whereType().map((op) => op.content).join(); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart new file mode 100644 index 0000000000..e9d3d62f54 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class FlowyCursorWidget extends StatefulWidget { + const FlowyCursorWidget({ + Key? key, + required this.layerLink, + required this.rect, + required this.color, + this.blinkingInterval = 0.5, + }) : super(key: key); + + final double blinkingInterval; + final Color color; + final Rect rect; + final LayerLink layerLink; + + @override + State createState() => _FlowyCursorWidgetState(); +} + +class _FlowyCursorWidgetState extends State { + bool showCursor = true; + late Timer timer; + + @override + void initState() { + super.initState(); + + timer = Timer.periodic( + Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()), + (timer) { + setState(() { + showCursor = !showCursor; + }); + }); + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned.fromRect( + rect: widget.rect, + child: CompositedTransformFollower( + link: widget.layerLink, + offset: Offset(widget.rect.center.dx, 0), + showWhenUnlinked: true, + child: Container( + color: showCursor ? widget.color : Colors.transparent, + ), + ), + ); + } +} 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 96fad4340b..117c71c4ed 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -12,3 +12,4 @@ 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'; +export 'package:flowy_editor/flowy_keyboard_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 index 01b4fdf419..b10f1282cd 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart @@ -8,9 +8,11 @@ class FlowyEditor extends StatefulWidget { const FlowyEditor({ Key? key, required this.editorState, + required this.keyEventHandler, }) : super(key: key); final EditorState editorState; + final List keyEventHandler; @override State createState() => _FlowyEditorState(); @@ -25,9 +27,8 @@ class _FlowyEditorState extends State { editorState: editorState, child: FlowyKeyboardWidget( handlers: [ - FlowyKeyboradBackSpaceHandler( - editorState: editorState, - ) + flowyDeleteNodesHandler, + ...widget.keyEventHandler, ], 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 index c7752abae5..65ab52dac9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart @@ -1,53 +1,31 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/operation/transaction.dart'; import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/selectable.dart'; import 'package:flutter/services.dart'; import 'editor_state.dart'; import 'package:flutter/material.dart'; -abstract class FlowyKeyboardHandler { - final EditorState editorState; +typedef FlowyKeyEventHandler = KeyEventResult Function( + EditorState editorState, + RawKeyEvent event, +); - FlowyKeyboardHandler({ - required this.editorState, - }); - - KeyEventResult onKeyDown(RawKeyEvent event); -} - -class FlowyKeyboradBackSpaceHandler extends FlowyKeyboardHandler { - FlowyKeyboradBackSpaceHandler({ - required super.editorState, - }); - - @override - KeyEventResult onKeyDown(RawKeyEvent event) { - final selectedNodes = editorState.selectedNodes; - if (selectedNodes.isNotEmpty) { - // handle delete text - // TODO: type: cursor or selection - if (selectedNodes.length == 1) { - final node = selectedNodes.first; - if (node is TextNode) { - final selectable = node.key?.currentState as Selectable?; - final textSelection = selectable?.getTextSelection(); - if (textSelection != null) { - if (textSelection.isCollapsed) { - TransactionBuilder(editorState) - ..deleteText(node, textSelection.start - 1, 1) - ..commit(); - // TODO: update selection?? - } - } - } - } - return KeyEventResult.handled; - } +FlowyKeyEventHandler flowyDeleteNodesHandler = (editorState, event) { + // Handle delete nodes. + final nodes = editorState.selectedNodes; + if (nodes.length <= 1) { return KeyEventResult.ignored; } -} + + debugPrint('delete nodes = $nodes'); + + nodes + .fold( + TransactionBuilder(editorState), + (previousValue, node) => previousValue..deleteNode(node), + ) + .commit(); + return KeyEventResult.handled; +}; /// Process keyboard events class FlowyKeyboardWidget extends StatefulWidget { @@ -60,7 +38,7 @@ class FlowyKeyboardWidget extends StatefulWidget { final EditorState editorState; final Widget child; - final List handlers; + final List handlers; @override State createState() => _FlowyKeyboardWidgetState(); @@ -89,7 +67,7 @@ class _FlowyKeyboardWidgetState extends State { for (final handler in widget.handlers) { debugPrint('handle keyboard event $event by $handler'); - KeyEventResult result = handler.onKeyDown(event); + KeyEventResult result = handler(widget.editorState, event); switch (result) { case KeyEventResult.handled: @@ -97,7 +75,7 @@ class _FlowyKeyboardWidgetState extends State { case KeyEventResult.skipRemainingHandlers: return KeyEventResult.skipRemainingHandlers; case KeyEventResult.ignored: - break; + continue; } } 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 index b2a9f2b9c8..b460df9ec2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart @@ -1,3 +1,4 @@ +import 'package:flowy_editor/flowy_cursor_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -15,9 +16,9 @@ mixin _FlowySelectionService on State { /// Tap Offset? tapOffset; - void updateSelection(); + void updateSelection(Offset start, Offset end); - void updateCursor(); + void updateCursor(Offset offset); /// Returns selected node(s) /// Returns empty list if no nodes are being selected. @@ -66,6 +67,8 @@ class FlowySelectionWidget extends StatefulWidget { class _FlowySelectionWidgetState extends State with _FlowySelectionService { + final _cursorKey = GlobalKey(debugLabel: 'cursor'); + List selectionOverlays = []; EditorState get editorState => widget.editorState; @@ -98,14 +101,12 @@ class _FlowySelectionWidgetState extends State } @override - void updateSelection() { + void updateSelection(Offset start, Offset end) { _clearOverlay(); final nodes = selectedNodes; editorState.selectedNodes = nodes; - if (nodes.isEmpty || panStartOffset == null || panEndOffset == null) { - assert(panStartOffset == null); - assert(panEndOffset == null); + if (nodes.isEmpty) { return; } @@ -114,8 +115,8 @@ class _FlowySelectionWidgetState extends State continue; } final selectable = node.key?.currentState as Selectable; - final selectionRects = selectable.getSelectionRectsInSelection( - panStartOffset!, panEndOffset!); + final selectionRects = + selectable.getSelectionRectsInSelection(start, end); for (final rect in selectionRects) { final overlay = OverlayEntry( builder: ((context) => Positioned.fromRect( @@ -132,14 +133,9 @@ class _FlowySelectionWidgetState extends State } @override - void updateCursor() { + void updateCursor(Offset offset) { _clearOverlay(); - if (tapOffset == null) { - assert(tapOffset == null); - return; - } - final nodes = selectedNodes; editorState.selectedNodes = nodes; if (nodes.isEmpty) { @@ -151,13 +147,13 @@ class _FlowySelectionWidgetState extends State return; } final selectable = selectedNode.key?.currentState as Selectable; - final rect = selectable.getCursorRect(tapOffset!); + final rect = selectable.getCursorRect(offset); final cursor = OverlayEntry( - builder: ((context) => Positioned.fromRect( + builder: ((context) => FlowyCursorWidget( + key: _cursorKey, rect: rect, - child: Container( - color: Colors.blue, - ), + color: Colors.red, + layerLink: selectedNode.layerLink, )), ); selectionOverlays.add(cursor); @@ -251,7 +247,7 @@ class _FlowySelectionWidgetState extends State panStartOffset = null; panEndOffset = null; - updateCursor(); + updateCursor(tapOffset!); } void _onPanStart(DragStartDetails details) { @@ -268,7 +264,7 @@ class _FlowySelectionWidgetState extends State panEndOffset = details.globalPosition; tapOffset = null; - updateSelection(); + updateSelection(panStartOffset!, panEndOffset!); } void _onPanEnd(DragEndDetails details) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart index f349a0fe3d..a3d35f9dad 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart @@ -52,7 +52,10 @@ class NodeWidgetBuilder { builder: (_, __) => Consumer( builder: ((context, value, child) { debugPrint('Node changed, and rebuilding...'); - return build(context); + return CompositedTransformTarget( + link: node.layerLink, + child: build(context), + ); }), ), ); From f58a6c9523c917e240c3caa8b2594840deae863d Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 25 Jul 2022 11:07:20 +0800 Subject: [PATCH 10/11] feat: implement floating cursor and selection --- .../flowy_editor/example/assets/document.json | 27 +++++++++ .../example/lib/plugin/image_node_widget.dart | 13 ++--- .../lib/plugin/selected_text_node_widget.dart | 32 +++++------ .../flowy_editor/lib/editor_state.dart | 12 ++-- .../flowy_editor/lib/flowy_editor.dart | 6 +- .../selection}/flowy_cursor_widget.dart | 2 +- .../selection/flowy_selection_widget.dart | 34 +++++++++++ .../render/{ => selection}/selectable.dart | 4 +- .../{ => service}/flowy_editor_service.dart | 8 +-- .../{ => service}/flowy_keyboard_service.dart | 2 +- .../flowy_selection_service.dart | 57 +++++++++++-------- 11 files changed, 133 insertions(+), 64 deletions(-) rename frontend/app_flowy/packages/flowy_editor/lib/{ => render/selection}/flowy_cursor_widget.dart (96%) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_selection_widget.dart rename frontend/app_flowy/packages/flowy_editor/lib/render/{ => selection}/selectable.dart (66%) rename frontend/app_flowy/packages/flowy_editor/lib/{ => service}/flowy_editor_service.dart (79%) rename frontend/app_flowy/packages/flowy_editor/lib/{ => service}/flowy_keyboard_service.dart (98%) rename frontend/app_flowy/packages/flowy_editor/lib/{ => service}/flowy_selection_service.dart (82%) 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 e89a258206..350764f769 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -97,6 +97,33 @@ ], "attributes": {} }, + { + "type": "text", + "delta": [ + { + "insert": "Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.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/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index 934974ce8c..4b63e77f51 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,4 @@ import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/flowy_keyboard_service.dart'; import 'package:flutter/material.dart'; FlowyKeyEventHandler deleteSingleImageNode = (editorState, event) { @@ -50,20 +49,16 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { String get src => widget.node.attributes['image_src'] as String; @override - List getSelectionRectsInSelection(Offset start, Offset end) { + List getSelectionRectsInRange(Offset start, Offset end) { final renderBox = context.findRenderObject() as RenderBox; - final size = renderBox.size; - final boxOffset = renderBox.localToGlobal(Offset.zero); - return [boxOffset & size]; + return [Offset.zero & renderBox.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); + final size = Size(2, renderBox.size.height); + final cursorOffset = Offset(renderBox.size.width, 0); return cursorOffset & size; } 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 7ce7162b07..3783eab4fa 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 @@ -54,7 +54,10 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> _textKey.currentContext?.findRenderObject() as RenderParagraph; @override - List getSelectionRectsInSelection(Offset start, Offset end) { + List getSelectionRectsInRange(Offset start, Offset end) { + final localStart = _renderParagraph.globalToLocal(start); + final localEnd = _renderParagraph.globalToLocal(end); + var textSelection = TextSelection(baseOffset: 0, extentOffset: node.toRawString().length); // Returns select all if the start or end exceeds the size of the box @@ -62,20 +65,20 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> var rects = _computeSelectionRects(textSelection); _textSelection = textSelection; - if (end.dy > start.dy) { + if (localEnd.dy > localStart.dy) { // downward - if (end.dy >= rects.last.bottom) { + if (localEnd.dy >= rects.last.bottom) { return rects; } } else { // upward - if (end.dy <= rects.first.top) { + if (localEnd.dy <= rects.first.top) { return rects; } } - final selectionBaseOffset = _getTextPositionAtOffset(start).offset; - final selectionExtentOffset = _getTextPositionAtOffset(end).offset; + final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; + final selectionExtentOffset = _getTextPositionAtOffset(localEnd).offset; textSelection = TextSelection( baseOffset: selectionBaseOffset, extentOffset: selectionExtentOffset, @@ -86,7 +89,8 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> @override Rect getCursorRect(Offset start) { - final selectionBaseOffset = _getTextPositionAtOffset(start).offset; + final localStart = _renderParagraph.globalToLocal(start); + final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; final textSelection = TextSelection.collapsed(offset: selectionBaseOffset); _textSelection = textSelection; return _computeCursorRect(textSelection.baseOffset); @@ -99,7 +103,6 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> @override Widget build(BuildContext context) { - print('text rebuild $this'); Widget richText; if (kDebugMode) { richText = DebuggableRichText(text: node.toTextSpan(), textKey: _textKey); @@ -132,23 +135,18 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> } TextPosition _getTextPositionAtOffset(Offset offset) { - final textOffset = _renderParagraph.globalToLocal(offset); - return _renderParagraph.getPositionForOffset(textOffset); + return _renderParagraph.getPositionForOffset(offset); } List _computeSelectionRects(TextSelection selection) { final textBoxes = _renderParagraph.getBoxesForSelection(selection); - return textBoxes - .map((box) => - _renderParagraph.localToGlobal(box.toRect().topLeft) & - box.toRect().size) - .toList(); + return textBoxes.map((box) => box.toRect()).toList(); } Rect _computeCursorRect(int offset) { final position = TextPosition(offset: offset); - var cursorOffset = _renderParagraph.getOffsetForCaret(position, Rect.zero); - cursorOffset = _renderParagraph.localToGlobal(cursorOffset); + final cursorOffset = + _renderParagraph.getOffsetForCaret(position, Rect.zero); final cursorHeight = _renderParagraph.getFullHeightForCaret(position); if (cursorHeight != null) { const cursorWidth = 2; 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 ced48242e2..f1fa65b33d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,11 +1,13 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/operation/operation.dart'; import 'dart:async'; -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/undo_manager.dart'; import 'package:flutter/material.dart'; -import './document/selection.dart'; +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/document/state_tree.dart'; +import 'package:flowy_editor/operation/operation.dart'; +import 'package:flowy_editor/operation/transaction.dart'; +import 'package:flowy_editor/undo_manager.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; class ApplyOptions { /// This flag indicates that 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 117c71c4ed..19c94ef327 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -6,10 +6,10 @@ export 'package:flowy_editor/document/path.dart'; export 'package:flowy_editor/document/text_delta.dart'; export 'package:flowy_editor/render/render_plugins.dart'; export 'package:flowy_editor/render/node_widget_builder.dart'; -export 'package:flowy_editor/render/selectable.dart'; +export 'package:flowy_editor/render/selection/selectable.dart'; 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'; -export 'package:flowy_editor/flowy_keyboard_service.dart'; +export 'package:flowy_editor/service/flowy_editor_service.dart'; +export 'package:flowy_editor/service/flowy_keyboard_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_cursor_widget.dart similarity index 96% rename from frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart rename to frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_cursor_widget.dart index e9d3d62f54..9ab61e5c47 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_cursor_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_cursor_widget.dart @@ -49,7 +49,7 @@ class _FlowyCursorWidgetState extends State { rect: widget.rect, child: CompositedTransformFollower( link: widget.layerLink, - offset: Offset(widget.rect.center.dx, 0), + offset: widget.rect.topCenter, showWhenUnlinked: true, child: Container( color: showCursor ? widget.color : Colors.transparent, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_selection_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_selection_widget.dart new file mode 100644 index 0000000000..f3def681e1 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_selection_widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class FlowySelectionWidget extends StatefulWidget { + const FlowySelectionWidget({ + Key? key, + required this.layerLink, + required this.rect, + required this.color, + }) : super(key: key); + + final Color color; + final Rect rect; + final LayerLink layerLink; + + @override + State createState() => _FlowySelectionWidgetState(); +} + +class _FlowySelectionWidgetState extends State { + @override + Widget build(BuildContext context) { + return Positioned.fromRect( + rect: widget.rect, + child: CompositedTransformFollower( + link: widget.layerLink, + offset: widget.rect.topLeft, + showWhenUnlinked: true, + child: Container( + color: widget.color, + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart similarity index 66% rename from frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart rename to frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index 3631da106f..1ba8f32b53 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -4,9 +4,11 @@ import 'package:flutter/material.dart'; mixin Selectable on State { /// Returns a [Rect] list for overlay. /// [start] and [end] are global offsets. - List getSelectionRectsInSelection(Offset start, Offset end); + /// The return result must be an local offset. + List getSelectionRectsInRange(Offset start, Offset end); /// Returns a [Rect] for cursor. + /// The return result must be an local offset. Rect getCursorRect(Offset start); /// For [TextNode] only. diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart similarity index 79% rename from frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart index b10f1282cd..0703e75022 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart @@ -1,7 +1,7 @@ -import 'package:flowy_editor/flowy_keyboard_service.dart'; -import 'package:flowy_editor/flowy_selection_service.dart'; +import 'package:flowy_editor/service/flowy_keyboard_service.dart'; +import 'package:flowy_editor/service/flowy_selection_service.dart'; -import 'editor_state.dart'; +import '../editor_state.dart'; import 'package:flutter/material.dart'; class FlowyEditor extends StatefulWidget { @@ -23,7 +23,7 @@ class _FlowyEditorState extends State { @override Widget build(BuildContext context) { - return FlowySelectionWidget( + return FlowySelectionService( editorState: editorState, child: FlowyKeyboardWidget( handlers: [ diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart similarity index 98% rename from frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart index 65ab52dac9..68f295e0bd 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart @@ -1,7 +1,7 @@ import 'package:flowy_editor/operation/transaction_builder.dart'; import 'package:flutter/services.dart'; -import 'editor_state.dart'; +import '../editor_state.dart'; import 'package:flutter/material.dart'; typedef FlowyKeyEventHandler = KeyEventResult Function( diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart similarity index 82% rename from frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart index b460df9ec2..b75ea5703b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart @@ -1,10 +1,11 @@ -import 'package:flowy_editor/flowy_cursor_widget.dart'; +import 'package:flowy_editor/render/selection/flowy_cursor_widget.dart'; +import 'package:flowy_editor/render/selection/flowy_selection_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'editor_state.dart'; -import 'document/node.dart'; -import '../render/selectable.dart'; +import '../editor_state.dart'; +import '../document/node.dart'; +import '../render/selection/selectable.dart'; /// Process selection and cursor mixin _FlowySelectionService on State { @@ -51,8 +52,8 @@ mixin _FlowySelectionService on State { ); } -class FlowySelectionWidget extends StatefulWidget { - const FlowySelectionWidget({ +class FlowySelectionService extends StatefulWidget { + const FlowySelectionService({ Key? key, required this.editorState, required this.child, @@ -62,14 +63,15 @@ class FlowySelectionWidget extends StatefulWidget { final Widget child; @override - State createState() => _FlowySelectionWidgetState(); + State createState() => _FlowySelectionServiceState(); } -class _FlowySelectionWidgetState extends State +class _FlowySelectionServiceState extends State with _FlowySelectionService { final _cursorKey = GlobalKey(debugLabel: 'cursor'); - List selectionOverlays = []; + final List _selectionOverlays = []; + final List _cursorOverlays = []; EditorState get editorState => widget.editorState; @@ -102,7 +104,7 @@ class _FlowySelectionWidgetState extends State @override void updateSelection(Offset start, Offset end) { - _clearOverlay(); + _clearAllOverlayEntries(); final nodes = selectedNodes; editorState.selectedNodes = nodes; @@ -115,26 +117,24 @@ class _FlowySelectionWidgetState extends State continue; } final selectable = node.key?.currentState as Selectable; - final selectionRects = - selectable.getSelectionRectsInSelection(start, end); + final selectionRects = selectable.getSelectionRectsInRange(start, end); for (final rect in selectionRects) { final overlay = OverlayEntry( - builder: ((context) => Positioned.fromRect( + builder: ((context) => FlowySelectionWidget( + color: Colors.yellow.withAlpha(100), + layerLink: node.layerLink, rect: rect, - child: Container( - color: Colors.yellow.withAlpha(100), - ), )), ); - selectionOverlays.add(overlay); + _selectionOverlays.add(overlay); } } - Overlay.of(context)?.insertAll(selectionOverlays); + Overlay.of(context)?.insertAll(_selectionOverlays); } @override void updateCursor(Offset offset) { - _clearOverlay(); + _clearAllOverlayEntries(); final nodes = selectedNodes; editorState.selectedNodes = nodes; @@ -156,8 +156,8 @@ class _FlowySelectionWidgetState extends State layerLink: selectedNode.layerLink, )), ); - selectionOverlays.add(cursor); - Overlay.of(context)?.insertAll(selectionOverlays); + _cursorOverlays.add(cursor); + Overlay.of(context)?.insertAll(_cursorOverlays); } @override @@ -271,8 +271,19 @@ class _FlowySelectionWidgetState extends State // do nothing } - void _clearOverlay() { - selectionOverlays + void _clearAllOverlayEntries() { + _clearSelection(); + _clearCursor(); + } + + void _clearSelection() { + _selectionOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + } + + void _clearCursor() { + _cursorOverlays ..forEach((overlay) => overlay.remove()) ..clear(); } From e1d990e4ae2c92cf60fb36f5a5d6c65c60271c15 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 25 Jul 2022 14:14:40 +0800 Subject: [PATCH 11/11] feat: support floating selection and delete textnode --- .../flowy_editor/example/assets/document.json | 22 +++--- .../flowy_editor/example/lib/main.dart | 4 +- .../example/lib/plugin/image_node_widget.dart | 16 ++-- .../lib/plugin/selected_text_node_widget.dart | 8 ++ .../flowy_editor/lib/editor_state.dart | 3 + .../lib/extensions/object_extensions.dart | 8 ++ .../flowy_editor/lib/flowy_editor.dart | 3 +- .../lib/render/selection/selectable.dart | 3 + ...ditor_service.dart => editor_service.dart} | 12 ++- .../delete_nodes_handler.dart | 21 ++++++ .../delete_single_text_node_handler.dart | 73 +++++++++++++++++++ ...ard_service.dart => keyboard_service.dart} | 27 +------ ...on_service.dart => selection_service.dart} | 59 +++++++-------- 13 files changed, 176 insertions(+), 83 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart rename frontend/app_flowy/packages/flowy_editor/lib/service/{flowy_editor_service.dart => editor_service.dart} (64%) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_nodes_handler.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_single_text_node_handler.dart rename frontend/app_flowy/packages/flowy_editor/lib/service/{flowy_keyboard_service.dart => keyboard_service.dart} (65%) rename frontend/app_flowy/packages/flowy_editor/lib/service/{flowy_selection_service.dart => selection_service.dart} (82%) 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 350764f769..00ef06da5d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -74,7 +74,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "1. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -83,7 +83,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "2. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -92,7 +92,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "3. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -101,7 +101,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support." + "insert": "4. Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -110,7 +110,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "5. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -119,7 +119,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "6. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -128,7 +128,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "7. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -137,7 +137,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "8. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -146,7 +146,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "9. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -155,7 +155,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "10. Click the '?' at the bottom right for help and support." } ], "attributes": {} @@ -164,7 +164,7 @@ "type": "text", "delta": [ { - "insert": "Click the '?' at the bottom right for help and support." + "insert": "11. Click the '?' at the bottom right for help and support." } ], "attributes": {} 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 1e047a23b4..83960275e6 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -96,9 +96,7 @@ class _MyHomePageState extends State { ); return FlowyEditor( editorState: _editorState, - keyEventHandler: [ - deleteSingleImageNode, - ], + keyEventHandler: const [], ); } }, 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 4b63e77f51..389bfed320 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,17 +1,6 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/material.dart'; -FlowyKeyEventHandler deleteSingleImageNode = (editorState, event) { - final selectNodes = editorState.selectedNodes; - if (selectNodes.length != 1 || selectNodes.first.type != 'image') { - return KeyEventResult.ignored; - } - TransactionBuilder(editorState) - ..deleteNode(selectNodes.first) - ..commit(); - return KeyEventResult.handled; -}; - class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ required super.node, @@ -67,6 +56,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { return null; } + @override + Offset getOffsetByTextSelection(TextSelection textSelection) { + return Offset.zero; + } + @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 3783eab4fa..1124ec3cbb 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 @@ -93,6 +93,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; final textSelection = TextSelection.collapsed(offset: selectionBaseOffset); _textSelection = textSelection; + print('text selection = $textSelection'); return _computeCursorRect(textSelection.baseOffset); } @@ -101,6 +102,12 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> return _textSelection; } + @override + Offset getOffsetByTextSelection(TextSelection textSelection) { + final offset = _computeCursorRect(textSelection.baseOffset).center; + return _renderParagraph.localToGlobal(offset); + } + @override Widget build(BuildContext context) { Widget richText; @@ -148,6 +155,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> final cursorOffset = _renderParagraph.getOffsetForCaret(position, Rect.zero); final cursorHeight = _renderParagraph.getFullHeightForCaret(position); + print('offset = $offset, cursorHeight = $cursorHeight'); if (cursorHeight != null) { const cursorWidth = 2; return Rect.fromLTWH( 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 f1fa65b33d..04a5721ed9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -19,6 +19,9 @@ class ApplyOptions { }); } +// TODO +final selectionServiceKey = GlobalKey(); + class EditorState { final StateTree document; final RenderPlugins renderPlugins; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart new file mode 100644 index 0000000000..b1b6e53512 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart @@ -0,0 +1,8 @@ +extension FlowyObjectExtensions on Object { + T? unwrapOrNull() { + if (this is T) { + return this as T; + } + return null; + } +} 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 19c94ef327..3f8510d8b3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -11,5 +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/service/flowy_editor_service.dart'; -export 'package:flowy_editor/service/flowy_keyboard_service.dart'; +export 'package:flowy_editor/service/editor_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index 1ba8f32b53..59849c1a6a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -13,4 +13,7 @@ mixin Selectable on State { /// For [TextNode] only. TextSelection? getTextSelection(); + + /// For [TextNode] only. + Offset getOffsetByTextSelection(TextSelection textSelection); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart similarity index 64% rename from frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index 0703e75022..d0efac2a0f 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -1,5 +1,7 @@ -import 'package:flowy_editor/service/flowy_keyboard_service.dart'; -import 'package:flowy_editor/service/flowy_selection_service.dart'; +import 'package:flowy_editor/service/flowy_key_event_handlers/delete_nodes_handler.dart'; +import 'package:flowy_editor/service/flowy_key_event_handlers/delete_single_text_node_handler.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/service/selection_service.dart'; import '../editor_state.dart'; import 'package:flutter/material.dart'; @@ -23,11 +25,13 @@ class _FlowyEditorState extends State { @override Widget build(BuildContext context) { - return FlowySelectionService( + return FlowySelection( + key: selectionServiceKey, editorState: editorState, - child: FlowyKeyboardWidget( + child: FlowyKeyboard( handlers: [ flowyDeleteNodesHandler, + deleteSingleTextNodeHandler, ...widget.keyEventHandler, ], editorState: editorState, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_nodes_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_nodes_handler.dart new file mode 100644 index 0000000000..dda52612e9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_nodes_handler.dart @@ -0,0 +1,21 @@ +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flutter/material.dart'; + +FlowyKeyEventHandler flowyDeleteNodesHandler = (editorState, event) { + // Handle delete nodes. + final nodes = editorState.selectedNodes; + if (nodes.length <= 1) { + return KeyEventResult.ignored; + } + + debugPrint('delete nodes = $nodes'); + + nodes + .fold( + TransactionBuilder(editorState), + (previousValue, node) => previousValue..deleteNode(node), + ) + .commit(); + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_single_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_single_text_node_handler.dart new file mode 100644 index 0000000000..3c1c1c9e95 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_key_event_handlers/delete_single_text_node_handler.dart @@ -0,0 +1,73 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/extensions/object_extensions.dart'; +import 'package:flowy_editor/service/selection_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// TODO: need to be refactored, just a example code. +FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) { + if (event.logicalKey != LogicalKeyboardKey.backspace) { + return KeyEventResult.ignored; + } + + final selectionNodes = editorState.selectedNodes; + if (selectionNodes.length == 1 && selectionNodes.first is TextNode) { + final node = selectionNodes.first.unwrapOrNull(); + final selectable = node?.key?.currentState?.unwrapOrNull(); + if (selectable != null) { + final textSelection = selectable.getTextSelection(); + if (textSelection != null) { + if (textSelection.isCollapsed) { + /// Three cases: + /// Delete the zero character, + /// 1. if there is still text node in front of it, then merge them. + /// 2. if not, just ignore + /// Delete the non-zero character, + /// 3. delete the single character. + if (textSelection.baseOffset == 0) { + if (node?.previous != null && node?.previous is TextNode) { + final previous = node!.previous! as TextNode; + final newTextSelection = TextSelection.collapsed( + offset: previous.toRawString().length); + final selectionService = + selectionServiceKey.currentState as FlowySelectionService; + final previousSelectable = + previous.key?.currentState?.unwrapOrNull(); + final newOfset = previousSelectable + ?.getOffsetByTextSelection(newTextSelection); + if (newOfset != null) { + selectionService.updateCursor(newOfset); + } + // merge + TransactionBuilder(editorState) + ..deleteNode(node) + ..insertText( + previous, previous.toRawString().length, node.toRawString()) + ..commit(); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } else { + TransactionBuilder(editorState) + ..deleteText(node!, textSelection.baseOffset - 1, 1) + ..commit(); + final newTextSelection = + TextSelection.collapsed(offset: textSelection.baseOffset - 1); + final selectionService = + selectionServiceKey.currentState as FlowySelectionService; + final newOfset = + selectable.getOffsetByTextSelection(newTextSelection); + selectionService.updateCursor(newOfset); + return KeyEventResult.handled; + } + } + } + } + } + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart similarity index 65% rename from frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart index 68f295e0bd..060a9c98fb 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_keyboard_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart @@ -1,4 +1,3 @@ -import 'package:flowy_editor/operation/transaction_builder.dart'; import 'package:flutter/services.dart'; import '../editor_state.dart'; @@ -9,27 +8,9 @@ typedef FlowyKeyEventHandler = KeyEventResult Function( RawKeyEvent event, ); -FlowyKeyEventHandler flowyDeleteNodesHandler = (editorState, event) { - // Handle delete nodes. - final nodes = editorState.selectedNodes; - if (nodes.length <= 1) { - return KeyEventResult.ignored; - } - - debugPrint('delete nodes = $nodes'); - - nodes - .fold( - TransactionBuilder(editorState), - (previousValue, node) => previousValue..deleteNode(node), - ) - .commit(); - return KeyEventResult.handled; -}; - /// Process keyboard events -class FlowyKeyboardWidget extends StatefulWidget { - const FlowyKeyboardWidget({ +class FlowyKeyboard extends StatefulWidget { + const FlowyKeyboard({ Key? key, required this.handlers, required this.editorState, @@ -41,10 +22,10 @@ class FlowyKeyboardWidget extends StatefulWidget { final List handlers; @override - State createState() => _FlowyKeyboardWidgetState(); + State createState() => _FlowyKeyboardState(); } -class _FlowyKeyboardWidgetState extends State { +class _FlowyKeyboardState extends State { final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); @override diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart similarity index 82% rename from frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart rename to frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index b75ea5703b..99b0efb467 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/flowy_selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -8,7 +8,7 @@ import '../document/node.dart'; import '../render/selection/selectable.dart'; /// Process selection and cursor -mixin _FlowySelectionService on State { +mixin FlowySelectionService on State { /// [Pan] and [Tap] must be mutually exclusive. /// Pan Offset? panStartOffset; @@ -19,20 +19,20 @@ mixin _FlowySelectionService on State { void updateSelection(Offset start, Offset end); - void updateCursor(Offset offset); + void updateCursor(Offset start); /// Returns selected node(s) /// Returns empty list if no nodes are being selected. - List get selectedNodes; + List getSelectedNodes(Offset start, [Offset? end]); /// Compute selected node triggered by [Tap] - Node? computeSelectedNodeByTap( + Node? computeSelectedNodeInOffset( Node node, Offset offset, ); /// Compute selected nodes triggered by [Pan] - List computeSelectedNodesByPan( + List computeSelectedNodesInRange( Node node, Offset start, Offset end, @@ -52,8 +52,8 @@ mixin _FlowySelectionService on State { ); } -class FlowySelectionService extends StatefulWidget { - const FlowySelectionService({ +class FlowySelection extends StatefulWidget { + const FlowySelection({ Key? key, required this.editorState, required this.child, @@ -63,11 +63,11 @@ class FlowySelectionService extends StatefulWidget { final Widget child; @override - State createState() => _FlowySelectionServiceState(); + State createState() => _FlowySelectionState(); } -class _FlowySelectionServiceState extends State - with _FlowySelectionService { +class _FlowySelectionState extends State + with FlowySelectionService { final _cursorKey = GlobalKey(debugLabel: 'cursor'); final List _selectionOverlays = []; @@ -106,7 +106,7 @@ class _FlowySelectionServiceState extends State void updateSelection(Offset start, Offset end) { _clearAllOverlayEntries(); - final nodes = selectedNodes; + final nodes = getSelectedNodes(start, end); editorState.selectedNodes = nodes; if (nodes.isEmpty) { return; @@ -133,10 +133,10 @@ class _FlowySelectionServiceState extends State } @override - void updateCursor(Offset offset) { + void updateCursor(Offset start) { _clearAllOverlayEntries(); - final nodes = selectedNodes; + final nodes = getSelectedNodes(start); editorState.selectedNodes = nodes; if (nodes.isEmpty) { return; @@ -147,7 +147,7 @@ class _FlowySelectionServiceState extends State return; } final selectable = selectedNode.key?.currentState as Selectable; - final rect = selectable.getCursorRect(offset); + final rect = selectable.getCursorRect(start); final cursor = OverlayEntry( builder: ((context) => FlowyCursorWidget( key: _cursorKey, @@ -161,13 +161,18 @@ class _FlowySelectionServiceState extends State } @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!); + List getSelectedNodes(Offset start, [Offset? end]) { + if (end != null) { + return computeSelectedNodesInRange( + editorState.document.root, + start, + end, + ); + } else { + final reuslt = computeSelectedNodeInOffset( + editorState.document.root, + start, + ); if (reuslt != null) { return [reuslt]; } @@ -176,13 +181,9 @@ class _FlowySelectionServiceState extends State } @override - Node? computeSelectedNodeByTap(Node node, Offset offset) { - assert(this.tapOffset != null); - final tapOffset = this.tapOffset; - if (tapOffset != null) {} - + Node? computeSelectedNodeInOffset(Node node, Offset offset) { for (final child in node.children) { - final result = computeSelectedNodeByTap(child, offset); + final result = computeSelectedNodeInOffset(child, offset); if (result != null) { return result; } @@ -198,7 +199,7 @@ class _FlowySelectionServiceState extends State } @override - List computeSelectedNodesByPan(Node node, Offset start, Offset end) { + List computeSelectedNodesInRange(Node node, Offset start, Offset end) { List result = []; if (node.parent != null && node.key != null) { if (isNodeInSelection(node, start, end)) { @@ -206,7 +207,7 @@ class _FlowySelectionServiceState extends State } } for (final child in node.children) { - result.addAll(computeSelectedNodesByPan(child, start, end)); + result.addAll(computeSelectedNodesInRange(child, start, end)); } // TODO: sort the result return result;