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); +}