feat: add keyboard and cursor

This commit is contained in:
Lucas.Xu 2022-07-21 17:56:56 +08:00
parent e3e1d25494
commit d200371002
8 changed files with 216 additions and 28 deletions

View File

@ -3,12 +3,6 @@
"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": [

View File

@ -50,7 +50,7 @@ class _EditorNodeWidget extends StatelessWidget {
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recongizer) {
recongizer..onTap = _onTap;
recongizer..onTapDown = _onTapDown;
},
)
},
@ -73,10 +73,13 @@ class _EditorNodeWidget extends StatelessWidget {
);
}
void _onTap() {
void _onTapDown(TapDownDetails details) {
editorState.panStartOffset = null;
editorState.panEndOffset = null;
editorState.updateSelection();
editorState.tapOffset = details.globalPosition;
editorState.updateCursor();
}
void _onPanStart(DragStartDetails details) {

View File

@ -1,5 +1,6 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class ImageNodeBuilder extends NodeWidgetBuilder {
ImageNodeBuilder.create({
@ -32,7 +33,8 @@ class _ImageNodeWidget extends StatefulWidget {
State<_ImageNodeWidget> createState() => __ImageNodeWidgetState();
}
class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
class __ImageNodeWidgetState extends State<_ImageNodeWidget>
with Selectable, KeyboardEventsRespondable {
Node get node => widget.node;
EditorState get editorState => widget.editorState;
String get src => widget.node.attributes['image_src'] as String;
@ -45,6 +47,27 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
return [boxOffset & size];
}
@override
Rect getCursorRect(Offset start) {
final renderBox = context.findRenderObject() as RenderBox;
final size = Size(5, renderBox.size.height);
final boxOffset = renderBox.localToGlobal(Offset.zero);
final cursorOffset =
Offset(renderBox.size.width + boxOffset.dx, boxOffset.dy);
return cursorOffset & size;
}
@override
KeyEventResult onKeyDown(RawKeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.backspace) {
TransactionBuilder(editorState)
..deleteNode(node)
..commit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return _build(context);

View File

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SelectedTextNodeBuilder extends NodeWidgetBuilder {
@ -43,22 +44,24 @@ class _SelectedTextNodeWidget extends StatefulWidget {
}
class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
with Selectable {
with Selectable, KeyboardEventsRespondable {
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> getOverlayRectsInRange(Offset start, Offset 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(baseOffset: 0, extentOffset: node.toRawString().length),
);
var rects = _computeSelectionRects(textSelection);
_textSelection = textSelection;
if (end.dy > start.dy) {
// downward
@ -74,13 +77,44 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
final selectionBaseOffset = _getTextPositionAtOffset(start).offset;
final selectionExtentOffset = _getTextPositionAtOffset(end).offset;
final textSelection = TextSelection(
textSelection = TextSelection(
baseOffset: selectionBaseOffset,
extentOffset: selectionExtentOffset,
);
_textSelection = textSelection;
return _computeSelectionRects(textSelection);
}
@override
Rect getCursorRect(Offset start) {
final selectionBaseOffset = _getTextPositionAtOffset(start).offset;
final textSelection = TextSelection.collapsed(offset: selectionBaseOffset);
_textSelection = textSelection;
return _computeCursorRect(textSelection.baseOffset);
}
@override
KeyEventResult onKeyDown(RawKeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.backspace) {
final textSelection = _textSelection;
// TODO: just handle upforward delete.
if (textSelection != null) {
if (textSelection.isCollapsed) {
TransactionBuilder(editorState)
..deleteText(node, textSelection.start - 1, 1)
..commit();
} else {
TransactionBuilder(editorState)
..deleteText(node, textSelection.start,
textSelection.baseOffset - textSelection.extentOffset)
..commit();
}
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
Widget richText;
@ -124,6 +158,20 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
box.toRect().size)
.toList();
}
Rect _computeCursorRect(int offset) {
final position = TextPosition(offset: offset);
var cursorOffset = _renderParagraph.getOffsetForCaret(position, Rect.zero);
cursorOffset = _renderParagraph.localToGlobal(cursorOffset);
final cursorHeight = _renderParagraph.getFullHeightForCaret(position)!;
const cursorWidth = 2;
return Rect.fromLTWH(
cursorOffset.dx - (cursorWidth / 2),
cursorOffset.dy,
cursorWidth.toDouble(),
cursorHeight.toDouble(),
);
}
}
extension on TextNode {

View File

@ -1,4 +1,7 @@
import 'dart:collection';
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/keyboard.dart';
import 'package:flowy_editor/operation/operation.dart';
import 'package:flowy_editor/render/selectable.dart';
import 'package:flutter/material.dart';
@ -13,6 +16,7 @@ class EditorState {
final StateTree document;
final RenderPlugins renderPlugins;
Offset? tapOffset;
Offset? panStartOffset;
Offset? panEndOffset;
@ -25,11 +29,14 @@ class EditorState {
/// TODO: move to a better place.
Widget build(BuildContext context) {
return renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: document.root,
editorState: this,
return Keyboard(
editorState: this,
child: renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: document.root,
editorState: this,
),
),
);
}
@ -55,18 +62,45 @@ class EditorState {
List<OverlayEntry> selectionOverlays = [];
void updateCursor() {
if (tapOffset == null) {
return;
}
// TODO: upward and backward
final selectedNode = _calculateSelectedNode(document.root, tapOffset!);
if (selectedNode.isEmpty) {
return;
}
final key = selectedNode.first.key;
if (key != null && key.currentState is Selectable) {
final selectable = key.currentState as Selectable;
final rect = selectable.getCursorRect(tapOffset!);
final overlay = OverlayEntry(builder: ((context) {
return Positioned.fromRect(
rect: rect,
child: Container(
color: Colors.red,
),
);
}));
selectionOverlays.add(overlay);
Overlay.of(selectable.context)?.insert(overlay);
}
}
void updateSelection() {
selectionOverlays
..forEach((element) => element.remove())
..clear();
final selectedNodes = _selectedNodes;
if (selectedNodes.isEmpty) {
final selectedNodes = this.selectedNodes;
if (selectedNodes.isEmpty ||
panStartOffset == null ||
panEndOffset == null) {
return;
}
assert(panStartOffset != null && panEndOffset != null);
for (final node in selectedNodes) {
final key = node.key;
if (key != null && key.currentState is Selectable) {
@ -90,12 +124,46 @@ class EditorState {
}
}
List<Node> get _selectedNodes {
if (panStartOffset == null || panEndOffset == null) {
return [];
List<Node> get selectedNodes {
if (panStartOffset != null && panEndOffset != null) {
return _calculateSelectedNodes(
document.root, panStartOffset!, panEndOffset!);
}
return _calculateSelectedNodes(
document.root, panStartOffset!, panEndOffset!);
if (tapOffset != null) {
return _calculateSelectedNode(document.root, tapOffset!);
}
return [];
}
List<Node> _calculateSelectedNode(Node node, Offset offset) {
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 (_isNodeInOffset(node, offset)) {
result.add(node);
}
}
///
for (final child in node.children) {
result.addAll(_calculateSelectedNode(child, offset));
}
return result;
}
bool _isNodeInOffset(Node node, Offset offset) {
assert(node.key != null);
final renderBox =
node.key?.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) {
return false;
}
final boxOffset = renderBox.localToGlobal(Offset.zero);
final boxRect = boxOffset & renderBox.size;
return boxRect.contains(offset);
}
List<Node> _calculateSelectedNodes(Node node, Offset start, Offset end) {

View File

@ -0,0 +1,45 @@
import 'package:flutter/services.dart';
import '../render/selectable.dart';
import 'editor_state.dart';
import 'package:flutter/material.dart';
class Keyboard extends StatelessWidget {
final Widget child;
final focusNode = FocusNode();
final EditorState editorState;
Keyboard({
Key? key,
required this.child,
required this.editorState,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Focus(
focusNode: focusNode,
autofocus: true,
onKey: _onKey,
child: child,
);
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
}
List<KeyEventResult> result = [];
for (final node in editorState.selectedNodes) {
if (node.key != null &&
node.key?.currentState is KeyboardEventsRespondable) {
final respondable = node.key!.currentState as KeyboardEventsRespondable;
result.add(respondable.onKeyDown(event));
}
}
if (result.contains(KeyEventResult.handled)) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
}

View File

@ -5,4 +5,11 @@ 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);
/// Returns a [Offset] for cursor
Rect getCursorRect(Offset start);
}
mixin KeyboardEventsRespondable<T extends StatefulWidget> on State<T> {
KeyEventResult onKeyDown(RawKeyEvent event);
}