mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add keyboard and cursor
This commit is contained in:
parent
e3e1d25494
commit
d200371002
@ -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": [
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
45
frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart
Normal file
45
frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user