mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support selection overlay
This commit is contained in:
parent
ce953d802a
commit
e2f35dd5cc
frontend/app_flowy/packages/flowy_editor
example
.vscode
lib
lib
25
frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json
vendored
Normal file
25
frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:example/plugin/document_node_widget.dart';
|
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/text_with_heading_node_widget.dart';
|
||||||
import 'package:example/plugin/image_node_widget.dart';
|
import 'package:example/plugin/image_node_widget.dart';
|
||||||
import 'package:example/plugin/text_node_widget.dart';
|
import 'package:example/plugin/text_node_widget.dart';
|
||||||
@ -65,7 +66,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
|
|
||||||
renderPlugins
|
renderPlugins
|
||||||
..register('editor', EditorNodeWidgetBuilder.create)
|
..register('editor', EditorNodeWidgetBuilder.create)
|
||||||
..register('text', TextNodeBuilder.create)
|
..register('text', SelectedTextNodeBuilder.create)
|
||||||
..register('image', ImageNodeBuilder.create)
|
..register('image', ImageNodeBuilder.create)
|
||||||
..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
|
..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
|
||||||
..register('text/with-heading', TextWithHeadingNodeBuilder.create);
|
..register('text/with-heading', TextWithHeadingNodeBuilder.create);
|
||||||
|
102
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart
Normal file
102
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,15 +1,18 @@
|
|||||||
import 'package:flowy_editor/flowy_editor.dart';
|
import 'package:flowy_editor/flowy_editor.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class EditorNodeWidgetBuilder extends NodeWidgetBuilder {
|
class EditorNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||||
EditorNodeWidgetBuilder.create({
|
EditorNodeWidgetBuilder.create({
|
||||||
required super.editorState,
|
required super.editorState,
|
||||||
required super.node,
|
required super.node,
|
||||||
|
required super.key,
|
||||||
}) : super.create();
|
}) : super.create();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext buildContext) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
key: key,
|
||||||
child: _EditorNodeWidget(
|
child: _EditorNodeWidget(
|
||||||
node: node,
|
node: node,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
@ -30,21 +33,49 @@ class _EditorNodeWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return RawGestureDetector(
|
||||||
child: Column(
|
behavior: HitTestBehavior.translucent,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
gestures: {
|
||||||
children: node.children
|
PanGestureRecognizer:
|
||||||
.map(
|
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||||
(e) => editorState.renderPlugins.buildWidget(
|
() => PanGestureRecognizer(),
|
||||||
context: NodeWidgetContext(
|
(recognizer) {
|
||||||
buildContext: context,
|
recognizer
|
||||||
node: e,
|
..onStart = _onPanStart
|
||||||
editorState: editorState,
|
..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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,18 +5,20 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
|
|||||||
ImageNodeBuilder.create({
|
ImageNodeBuilder.create({
|
||||||
required super.node,
|
required super.node,
|
||||||
required super.editorState,
|
required super.editorState,
|
||||||
|
required super.key,
|
||||||
}) : super.create();
|
}) : super.create();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext buildContext) {
|
||||||
return _ImageNodeWidget(
|
return _ImageNodeWidget(
|
||||||
|
key: key,
|
||||||
node: node,
|
node: node,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageNodeWidget extends StatelessWidget {
|
class _ImageNodeWidget extends StatefulWidget {
|
||||||
final Node node;
|
final Node node;
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
|
|
||||||
@ -26,7 +28,22 @@ class _ImageNodeWidget extends StatelessWidget {
|
|||||||
required this.editorState,
|
required this.editorState,
|
||||||
}) : super(key: key);
|
}) : 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
223
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
Normal file
223
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ class TextNodeBuilder extends NodeWidgetBuilder {
|
|||||||
TextNodeBuilder.create({
|
TextNodeBuilder.create({
|
||||||
required super.node,
|
required super.node,
|
||||||
required super.editorState,
|
required super.editorState,
|
||||||
|
required super.key,
|
||||||
}) : super.create() {
|
}) : super.create() {
|
||||||
nodeValidator = ((node) {
|
nodeValidator = ((node) {
|
||||||
return node.type == 'text';
|
return node.type == 'text';
|
||||||
@ -20,7 +21,7 @@ class TextNodeBuilder extends NodeWidgetBuilder {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext buildContext) {
|
||||||
return _TextNodeWidget(node: node, editorState: editorState);
|
return _TextNodeWidget(key: key, node: node, editorState: editorState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
|
|||||||
TextWithCheckBoxNodeBuilder.create({
|
TextWithCheckBoxNodeBuilder.create({
|
||||||
required super.node,
|
required super.node,
|
||||||
required super.editorState,
|
required super.editorState,
|
||||||
|
required super.key,
|
||||||
}) : super.create();
|
}) : super.create();
|
||||||
|
|
||||||
// TODO: check the type
|
// TODO: check the type
|
||||||
|
@ -5,6 +5,7 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder {
|
|||||||
TextWithHeadingNodeBuilder.create({
|
TextWithHeadingNodeBuilder.create({
|
||||||
required super.editorState,
|
required super.editorState,
|
||||||
required super.node,
|
required super.node,
|
||||||
|
required super.key,
|
||||||
}) : super.create() {
|
}) : super.create() {
|
||||||
nodeValidator = (node) => node.attributes.containsKey('heading');
|
nodeValidator = (node) => node.attributes.containsKey('heading');
|
||||||
}
|
}
|
||||||
@ -15,9 +16,9 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder {
|
|||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.only(top: 10),
|
padding: EdgeInsets.only(top: 10),
|
||||||
);
|
);
|
||||||
} else if (heading == 'h1') {
|
} else if (heading == 'h2') {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.only(top: 10),
|
padding: EdgeInsets.only(top: 5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const Padding(
|
return const Padding(
|
||||||
|
@ -10,6 +10,8 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||||||
final LinkedList<Node> children;
|
final LinkedList<Node> children;
|
||||||
final Attributes attributes;
|
final Attributes attributes;
|
||||||
|
|
||||||
|
GlobalKey? key;
|
||||||
|
|
||||||
String? get subtype {
|
String? get subtype {
|
||||||
// TODO: make 'subtype' as a const value.
|
// TODO: make 'subtype' as a const value.
|
||||||
if (attributes.containsKey('subtype')) {
|
if (attributes.containsKey('subtype')) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/operation/operation.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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import './document/state_tree.dart';
|
import './document/state_tree.dart';
|
||||||
@ -12,6 +12,10 @@ import './render/render_plugins.dart';
|
|||||||
class EditorState {
|
class EditorState {
|
||||||
final StateTree document;
|
final StateTree document;
|
||||||
final RenderPlugins renderPlugins;
|
final RenderPlugins renderPlugins;
|
||||||
|
|
||||||
|
Offset? panStartOffset;
|
||||||
|
Offset? panEndOffset;
|
||||||
|
|
||||||
Selection? cursorSelection;
|
Selection? cursorSelection;
|
||||||
|
|
||||||
EditorState({
|
EditorState({
|
||||||
@ -48,4 +52,82 @@ class EditorState {
|
|||||||
document.textEdit(op.path, op.delta);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,10 @@ library flowy_editor;
|
|||||||
export 'package:flowy_editor/document/state_tree.dart';
|
export 'package:flowy_editor/document/state_tree.dart';
|
||||||
export 'package:flowy_editor/document/node.dart';
|
export 'package:flowy_editor/document/node.dart';
|
||||||
export 'package:flowy_editor/document/path.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/render_plugins.dart';
|
||||||
export 'package:flowy_editor/render/node_widget_builder.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.dart';
|
||||||
export 'package:flowy_editor/operation/transaction_builder.dart';
|
export 'package:flowy_editor/operation/transaction_builder.dart';
|
||||||
export 'package:flowy_editor/operation/operation.dart';
|
export 'package:flowy_editor/operation/operation.dart';
|
||||||
|
@ -9,6 +9,7 @@ typedef NodeValidator<T extends Node> = bool Function(T node);
|
|||||||
class NodeWidgetBuilder<T extends Node> {
|
class NodeWidgetBuilder<T extends Node> {
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
final T node;
|
final T node;
|
||||||
|
final Key key;
|
||||||
|
|
||||||
bool rebuildOnNodeChanged;
|
bool rebuildOnNodeChanged;
|
||||||
NodeValidator<T>? nodeValidator;
|
NodeValidator<T>? nodeValidator;
|
||||||
@ -18,14 +19,22 @@ class NodeWidgetBuilder<T extends Node> {
|
|||||||
NodeWidgetBuilder.create({
|
NodeWidgetBuilder.create({
|
||||||
required this.editorState,
|
required this.editorState,
|
||||||
required this.node,
|
required this.node,
|
||||||
|
required this.key,
|
||||||
this.rebuildOnNodeChanged = true,
|
this.rebuildOnNodeChanged = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Render the current [Node]
|
/// Render the current [Node]
|
||||||
/// and the layout style of [Node.Children].
|
/// 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
|
/// TODO: Validate the node
|
||||||
/// if failed, stop call build function,
|
/// if failed, stop call build function,
|
||||||
/// return Empty widget, and throw Error.
|
/// 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} }');
|
'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rebuildOnNodeChanged) {
|
return _buildNodeChangeNotifier(buildContext);
|
||||||
return _buildNodeChangeNotifier(buildContext);
|
|
||||||
} else {
|
|
||||||
return build(buildContext);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNodeChangeNotifier(BuildContext buildContext) {
|
Widget _buildNodeChangeNotifier(BuildContext buildContext) {
|
||||||
|
@ -19,6 +19,7 @@ typedef NodeWidgetBuilderF<T extends Node, A extends NodeWidgetBuilder> = A
|
|||||||
Function({
|
Function({
|
||||||
required T node,
|
required T node,
|
||||||
required EditorState editorState,
|
required EditorState editorState,
|
||||||
|
required GlobalKey key,
|
||||||
});
|
});
|
||||||
|
|
||||||
// unused
|
// unused
|
||||||
@ -63,9 +64,12 @@ class RenderPlugins {
|
|||||||
name += '/${node.subtype}';
|
name += '/${node.subtype}';
|
||||||
}
|
}
|
||||||
final nodeWidgetBuilder = _nodeWidgetBuilder(name);
|
final nodeWidgetBuilder = _nodeWidgetBuilder(name);
|
||||||
|
final key = GlobalKey();
|
||||||
|
node.key = key;
|
||||||
return nodeWidgetBuilder(
|
return nodeWidgetBuilder(
|
||||||
node: context.node,
|
node: context.node,
|
||||||
editorState: context.editorState,
|
editorState: context.editorState,
|
||||||
|
key: key,
|
||||||
)(context.buildContext);
|
)(context.buildContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user