Merge pull request #667 from LucasXu0/feat/flowy_editor

feat: render selection and cursor.
This commit is contained in:
Nathan.fooo 2022-07-25 14:17:15 +08:00 committed by GitHub
commit 8b11028aa1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1325 additions and 105 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,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"
}
]
}
}
},
{
"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": {}
}
]
}
}

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);
@ -93,7 +94,10 @@ class _MyHomePageState extends State<MyHomePage> {
document: document,
renderPlugins: renderPlugins,
);
return _editorState.build(context);
return FlowyEditor(
editorState: _editorState,
keyEventHandler: const [],
);
}
},
),

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

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

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

View File

@ -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<Rect> 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<Rect> _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<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);
}
}
@ -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<String, dynamic> 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<TextEditingDelta> textEditingDeltas) {
debugPrint(textEditingDeltas.toString());
for (final textDelta in textEditingDeltas) {
if (textDelta is TextEditingDeltaInsertion) {
TransactionBuilder(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,10 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
final LinkedList<Node> 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<TextInsert>()
.map((op) => op.content)
.toString();
// TODO: It's unneccesry to compute everytime.
String toRawString() =>
_delta.operations.whereType<TextInsert>().map((op) => op.content).join();
}

View File

@ -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<Node> selectedNodes = [];
final UndoManager undoManager = UndoManager();
Selection? cursorSelection;

View File

@ -0,0 +1,8 @@
extension FlowyObjectExtensions on Object {
T? unwrapOrNull<T>() {
if (this is T) {
return this as T;
}
return null;
}
}

View File

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

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) {
@ -47,7 +52,10 @@ class NodeWidgetBuilder<T extends Node> {
builder: (_, __) => Consumer<T>(
builder: ((context, value, child) {
debugPrint('Node changed, and rebuilding...');
return build(context);
return CompositedTransformTarget(
link: node.layerLink,
child: build(context),
);
}),
),
);

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,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<FlowyCursorWidget> createState() => _FlowyCursorWidgetState();
}
class _FlowyCursorWidgetState extends State<FlowyCursorWidget> {
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,
),
),
);
}
}

View File

@ -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<FlowySelectionWidget> createState() => _FlowySelectionWidgetState();
}
class _FlowySelectionWidgetState extends State<FlowySelectionWidget> {
@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,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
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.
/// The return result must be an local offset.
List<Rect> 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);
}

View File

@ -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<FlowyKeyEventHandler> keyEventHandler;
@override
State<FlowyEditor> createState() => _FlowyEditorState();
}
class _FlowyEditorState extends State<FlowyEditor> {
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),
),
);
}
}

View File

@ -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>(
TransactionBuilder(editorState),
(previousValue, node) => previousValue..deleteNode(node),
)
.commit();
return KeyEventResult.handled;
};

View File

@ -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<TextNode>();
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
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<Selectable>();
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;
};

View File

@ -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<FlowyKeyEventHandler> handlers;
@override
State<FlowyKeyboard> createState() => _FlowyKeyboardState();
}
class _FlowyKeyboardState extends State<FlowyKeyboard> {
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;
}
}

View File

@ -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<T extends StatefulWidget> on State<T> {
/// [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<Node> getSelectedNodes(Offset start, [Offset? end]);
/// Compute selected node triggered by [Tap]
Node? computeSelectedNodeInOffset(
Node node,
Offset offset,
);
/// Compute selected nodes triggered by [Pan]
List<Node> 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<FlowySelection> createState() => _FlowySelectionState();
}
class _FlowySelectionState extends State<FlowySelection>
with FlowySelectionService {
final _cursorKey = GlobalKey(debugLabel: 'cursor');
final List<OverlayEntry> _selectionOverlays = [];
final List<OverlayEntry> _cursorOverlays = [];
EditorState get editorState => widget.editorState;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(recognizer) {
recognizer
..onStart = _onPanStart
..onUpdate = _onPanUpdate
..onEnd = _onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => 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<Node> 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<Node> computeSelectedNodesInRange(Node node, Offset start, Offset end) {
List<Node> 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();
}
}