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/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index c2ba9fbb09..00ef06da5d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -1,77 +1,228 @@ { - "document": { - "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": [ - { "insert": "👋 Welcome to AppFlowy!", "attributes": { "href": "https://www.appflowy.io/", "heading": "h1" } } - ], - "attributes": { - "subtype": "with-heading", - "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": {} + "document": { + "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" } - ] - } - } \ No newline at end of file + }, + { + "type": "text", + "delta": [ + { + "insert": "👋 Welcome to AppFlowy!", + "attributes": { + "href": "https://www.appflowy.io/", + "heading": "h1" + } + } + ], + "attributes": { + "heading": "h1" + } + }, + { + "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": "1. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "2. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "3. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "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": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "5. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "6. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "7. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "8. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "9. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "10. Click the '?' at the bottom right for help and support." + } + ], + "attributes": {} + }, + { + "type": "text", + "delta": [ + { + "insert": "11. 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": {} + } + ] + } +} 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..83960275e6 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); @@ -93,7 +94,10 @@ class _MyHomePageState extends State { document: document, renderPlugins: renderPlugins, ); - return _editorState.build(context); + return FlowyEditor( + editorState: _editorState, + keyEventHandler: const [], + ); } }, ), 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..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 @@ -5,11 +5,13 @@ 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, 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..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 @@ -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,38 @@ 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 getSelectionRectsInRange(Offset start, Offset end) { + final renderBox = context.findRenderObject() as RenderBox; + return [Offset.zero & renderBox.size]; + } + + @override + Rect getCursorRect(Offset start) { + final renderBox = context.findRenderObject() as RenderBox; + final size = Size(2, renderBox.size.height); + final cursorOffset = Offset(renderBox.size.width, 0); + return cursorOffset & size; + } + + @override + TextSelection? getTextSelection() { + return null; + } + + @override + Offset getOffsetByTextSelection(TextSelection textSelection) { + return Offset.zero; + } @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..1124ec3cbb --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -0,0 +1,267 @@ +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(); + TextSelection? _textSelection; + + RenderParagraph get _renderParagraph => + _textKey.currentContext?.findRenderObject() as RenderParagraph; + + @override + 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 + // TODO: don't need to compute everytime. + var rects = _computeSelectionRects(textSelection); + _textSelection = textSelection; + + if (localEnd.dy > localStart.dy) { + // downward + if (localEnd.dy >= rects.last.bottom) { + return rects; + } + } else { + // upward + if (localEnd.dy <= rects.first.top) { + return rects; + } + } + + final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; + final selectionExtentOffset = _getTextPositionAtOffset(localEnd).offset; + textSelection = TextSelection( + baseOffset: selectionBaseOffset, + extentOffset: selectionExtentOffset, + ); + _textSelection = textSelection; + return _computeSelectionRects(textSelection); + } + + @override + Rect getCursorRect(Offset start) { + final localStart = _renderParagraph.globalToLocal(start); + final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; + final textSelection = TextSelection.collapsed(offset: selectionBaseOffset); + _textSelection = textSelection; + print('text selection = $textSelection'); + return _computeCursorRect(textSelection.baseOffset); + } + + @override + TextSelection? getTextSelection() { + return _textSelection; + } + + @override + Offset getOffsetByTextSelection(TextSelection textSelection) { + final offset = _computeCursorRect(textSelection.baseOffset).center; + return _renderParagraph.localToGlobal(offset); + } + + @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: [ + SizedBox( + width: MediaQuery.of(context).size.width, + child: 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) { + return _renderParagraph.getPositionForOffset(offset); + } + + List _computeSelectionRects(TextSelection selection) { + final textBoxes = _renderParagraph.getBoxesForSelection(selection); + return textBoxes.map((box) => box.toRect()).toList(); + } + + Rect _computeCursorRect(int offset) { + final position = TextPosition(offset: offset); + 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( + cursorOffset.dx - (cursorWidth / 2), + cursorOffset.dy, + cursorWidth.toDouble(), + cursorHeight.toDouble(), + ); + } else { + return Rect.zero; + } + } +} + +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 9e193be761..cfb0ea5383 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); } } @@ -126,7 +127,6 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> textCapitalization: TextCapitalization.sentences, ), ); - debugPrint('selection: $selection'); editorState.cursorSelection = _localSelectionToGlobal(node, selection); _textInputConnection ?..show() @@ -205,9 +205,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) { @@ -230,13 +228,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/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..8b80fd0b51 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,10 @@ class Node extends ChangeNotifier with LinkedListEntry { final LinkedList children; final Attributes attributes; + GlobalKey? key; + // TODO: abstract a selectable node?? + final layerLink = LayerLink(); + String? get subtype { // TODO: make 'subtype' as a const value. if (attributes.containsKey('subtype')) { @@ -184,8 +188,7 @@ class TextNode extends Node { return map; } - String toRawString() => _delta.operations - .whereType() - .map((op) => op.content) - .toString(); + // 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/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index ee0e625537..dfe734da8c 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,13 @@ 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 @@ -17,9 +21,13 @@ class ApplyOptions { }); } +// TODO +final selectionServiceKey = GlobalKey(); + class EditorState { final StateTree document; final RenderPlugins renderPlugins; + List selectedNodes = []; final UndoManager undoManager = UndoManager(); Selection? cursorSelection; 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 f816778603..3f8510d8b3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -3,9 +3,12 @@ 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/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/service/editor_service.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..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 @@ -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) { @@ -47,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), + ); }), ), ); 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/selection/flowy_cursor_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_cursor_widget.dart new file mode 100644 index 0000000000..9ab61e5c47 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/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: 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/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart new file mode 100644 index 0000000000..59849c1a6a --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// +mixin Selectable on State { + /// Returns a [Rect] list for overlay. + /// [start] and [end] are global offsets. + /// 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. + TextSelection? getTextSelection(); + + /// For [TextNode] only. + Offset getOffsetByTextSelection(TextSelection textSelection); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart new file mode 100644 index 0000000000..d0efac2a0f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -0,0 +1,42 @@ +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'; + +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(); +} + +class _FlowyEditorState extends State { + EditorState get editorState => widget.editorState; + + @override + Widget build(BuildContext context) { + return FlowySelection( + key: selectionServiceKey, + editorState: editorState, + child: FlowyKeyboard( + handlers: [ + flowyDeleteNodesHandler, + deleteSingleTextNodeHandler, + ...widget.keyEventHandler, + ], + editorState: editorState, + child: editorState.build(context), + ), + ); + } +} 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/keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart new file mode 100644 index 0000000000..060a9c98fb --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart @@ -0,0 +1,65 @@ +import 'package:flutter/services.dart'; + +import '../editor_state.dart'; +import 'package:flutter/material.dart'; + +typedef FlowyKeyEventHandler = KeyEventResult Function( + EditorState editorState, + RawKeyEvent event, +); + +/// Process keyboard events +class FlowyKeyboard extends StatefulWidget { + const FlowyKeyboard({ + 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() => _FlowyKeyboardState(); +} + +class _FlowyKeyboardState 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) { + debugPrint('on keyboard event $event'); + + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + for (final handler in widget.handlers) { + debugPrint('handle keyboard event $event by $handler'); + + KeyEventResult result = handler(widget.editorState, event); + + switch (result) { + case KeyEventResult.handled: + return KeyEventResult.handled; + case KeyEventResult.skipRemainingHandlers: + return KeyEventResult.skipRemainingHandlers; + case KeyEventResult.ignored: + continue; + } + } + + return KeyEventResult.ignored; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart new file mode 100644 index 0000000000..99b0efb467 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -0,0 +1,291 @@ +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/selection/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(Offset start, Offset end); + + void updateCursor(Offset start); + + /// Returns selected node(s) + /// Returns empty list if no nodes are being selected. + List getSelectedNodes(Offset start, [Offset? end]); + + /// Compute selected node triggered by [Tap] + Node? computeSelectedNodeInOffset( + Node node, + Offset offset, + ); + + /// Compute selected nodes triggered by [Pan] + List computeSelectedNodesInRange( + Node node, + Offset start, + Offset end, + ); + + /// Pan + bool isNodeInSelection( + Node node, + Offset start, + Offset end, + ); + + /// Tap + bool isNodeInOffset( + Node node, + Offset offset, + ); +} + +class FlowySelection extends StatefulWidget { + const FlowySelection({ + Key? key, + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + + @override + State createState() => _FlowySelectionState(); +} + +class _FlowySelectionState extends State + with FlowySelectionService { + final _cursorKey = GlobalKey(debugLabel: 'cursor'); + + final List _selectionOverlays = []; + final List _cursorOverlays = []; + + 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(Offset start, Offset end) { + _clearAllOverlayEntries(); + + final nodes = getSelectedNodes(start, end); + editorState.selectedNodes = nodes; + if (nodes.isEmpty) { + return; + } + + for (final node in nodes) { + if (node.key?.currentState is! Selectable) { + continue; + } + final selectable = node.key?.currentState as Selectable; + final selectionRects = selectable.getSelectionRectsInRange(start, end); + for (final rect in selectionRects) { + final overlay = OverlayEntry( + builder: ((context) => FlowySelectionWidget( + color: Colors.yellow.withAlpha(100), + layerLink: node.layerLink, + rect: rect, + )), + ); + _selectionOverlays.add(overlay); + } + } + Overlay.of(context)?.insertAll(_selectionOverlays); + } + + @override + void updateCursor(Offset start) { + _clearAllOverlayEntries(); + + final nodes = getSelectedNodes(start); + editorState.selectedNodes = nodes; + if (nodes.isEmpty) { + return; + } + + final selectedNode = nodes.first; + if (selectedNode.key?.currentState is! Selectable) { + return; + } + final selectable = selectedNode.key?.currentState as Selectable; + final rect = selectable.getCursorRect(start); + final cursor = OverlayEntry( + builder: ((context) => FlowyCursorWidget( + key: _cursorKey, + rect: rect, + color: Colors.red, + layerLink: selectedNode.layerLink, + )), + ); + _cursorOverlays.add(cursor); + Overlay.of(context)?.insertAll(_cursorOverlays); + } + + @override + 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]; + } + } + return []; + } + + @override + Node? computeSelectedNodeInOffset(Node node, Offset offset) { + for (final child in node.children) { + final result = computeSelectedNodeInOffset(child, offset); + if (result != null) { + return result; + } + } + + if (node.parent != null && node.key != null) { + if (isNodeInOffset(node, offset)) { + return node; + } + } + + return null; + } + + @override + List computeSelectedNodesInRange(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(computeSelectedNodesInRange(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(tapOffset!); + } + + 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(panStartOffset!, panEndOffset!); + } + + void _onPanEnd(DragEndDetails details) { + // do nothing + } + + void _clearAllOverlayEntries() { + _clearSelection(); + _clearCursor(); + } + + void _clearSelection() { + _selectionOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + } + + void _clearCursor() { + _cursorOverlays + ..forEach((overlay) => overlay.remove()) + ..clear(); + } +}