mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #667 from LucasXu0/feat/flowy_editor
feat: render selection and cursor.
This commit is contained in:
commit
8b11028aa1
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,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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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 [],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -0,0 +1,8 @@
|
||||
extension FlowyObjectExtensions on Object {
|
||||
T? unwrapOrNull<T>() {
|
||||
if (this is T) {
|
||||
return this as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user