feat: support selection overlay

This commit is contained in:
Lucas.Xu 2022-07-21 14:55:37 +08:00
parent ce953d802a
commit e2f35dd5cc
15 changed files with 532 additions and 27 deletions

View File

@ -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"
}
]
}

View File

@ -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<MyHomePage> {
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);

View File

@ -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<DebuggableRichText> createState() => _DebuggableRichTextState();
}
class _DebuggableRichTextState extends State<DebuggableRichText> {
final List<Rect> _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<Rect> _computeLocalSelectionRects(TextSelection selection) {
final textBoxes = _renderParagraph.getBoxesForSelection(selection);
return textBoxes.map((box) => box.toRect()).toList();
}
}
class _BoxPainter extends CustomPainter {
final List<Rect> _rects;
final Paint _paint;
_BoxPainter({
required List<Rect> 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;
}
}

View File

@ -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>(
() => 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
}
}

View File

@ -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<Rect> 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) {

View File

@ -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<Rect> 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<Rect> _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<TextInsert>()
.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<Rect> _rects;
final Paint _paint;
FlowyPainter({
Key? key,
required Color color,
required List<Rect> 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;
}
}

View File

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

View File

@ -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

View File

@ -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(

View File

@ -10,6 +10,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
final LinkedList<Node> children;
final Attributes attributes;
GlobalKey? key;
String? get subtype {
// TODO: make 'subtype' as a const value.
if (attributes.containsKey('subtype')) {

View File

@ -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<OverlayEntry> 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<Node> get _selectedNodes {
if (panStartOffset == null || panEndOffset == null) {
return [];
}
return _calculateSelectedNodes(
document.root, panStartOffset!, panEndOffset!);
}
List<Node> _calculateSelectedNodes(Node node, Offset start, Offset end) {
List<Node> 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);
}
}

View File

@ -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';

View File

@ -9,6 +9,7 @@ typedef NodeValidator<T extends Node> = bool Function(T node);
class NodeWidgetBuilder<T extends Node> {
final EditorState editorState;
final T node;
final Key key;
bool rebuildOnNodeChanged;
NodeValidator<T>? nodeValidator;
@ -18,14 +19,22 @@ class NodeWidgetBuilder<T extends Node> {
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<T extends Node> {
'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) {

View File

@ -19,6 +19,7 @@ typedef NodeWidgetBuilderF<T extends Node, A extends NodeWidgetBuilder> = 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);
}

View File

@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
///
mixin Selectable<T extends StatefulWidget> on State<T> {
/// Returns a [Rect] list for overlay.
/// [start] and [end] are global offsets.
List<Rect> getOverlayRectsInRange(Offset start, Offset end);
}