refactor: abstract selection and keyboard from editor state

This commit is contained in:
Lucas.Xu 2022-07-22 00:16:34 +08:00
parent eb97141859
commit a831ddc589
12 changed files with 524 additions and 67 deletions

View File

@ -64,6 +64,123 @@
"heading": "h2"
}
},
{
"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": {}
},
{
"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": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [

View File

@ -94,7 +94,9 @@ class _MyHomePageState extends State<MyHomePage> {
document: document,
renderPlugins: renderPlugins,
);
return _editorState.build(context);
return FlowyEditor(
editorState: _editorState,
);
}
},
),

View File

@ -33,66 +33,21 @@ class _EditorNodeWidget extends StatelessWidget {
@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: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.map(
(e) => editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: e,
editorState: editorState,
),
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: node.children
.map(
(e) => editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: e,
editorState: editorState,
),
)
.toList(),
),
),
)
.toList(),
),
);
}
void _onTapDown(TapDownDetails details) {
editorState.panStartOffset = null;
editorState.panEndOffset = null;
editorState.updateSelection();
editorState.tapOffset = details.globalPosition;
editorState.updateCursor();
}
void _onPanStart(DragStartDetails details) {
editorState.panStartOffset = details.globalPosition;
editorState.updateSelection();
}
void _onPanUpdate(DragUpdateDetails details) {
editorState.panEndOffset = details.globalPosition;
editorState.updateSelection();
}
void _onPanEnd(DragEndDetails details) {
// do nothing
}
}

View File

@ -40,7 +40,7 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget>
String get src => widget.node.attributes['image_src'] as String;
@override
List<Rect> getOverlayRectsInRange(Offset start, Offset end) {
List<Rect> getSelectionRectsInSelection(Offset start, Offset end) {
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final boxOffset = renderBox.localToGlobal(Offset.zero);

View File

@ -56,7 +56,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
_textKey.currentContext?.findRenderObject() as RenderParagraph;
@override
List<Rect> getOverlayRectsInRange(Offset start, Offset end) {
List<Rect> getSelectionRectsInSelection(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

View File

@ -109,8 +109,8 @@ class EditorState {
final key = node.key;
if (key != null && key.currentState is Selectable) {
final selectable = key.currentState as Selectable;
final overlayRects =
selectable.getOverlayRectsInRange(panStartOffset!, panEndOffset!);
final overlayRects = selectable.getSelectionRectsInSelection(
panStartOffset!, panEndOffset!);
for (final rect in overlayRects) {
// TODO: refactor overlay implement.
final overlay = OverlayEntry(builder: ((context) {

View File

@ -11,3 +11,4 @@ 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/flowy_editor_service.dart';

View File

@ -0,0 +1,33 @@
import 'package:flowy_editor/flowy_keyboard_service.dart';
import 'package:flowy_editor/flowy_selection_service.dart';
import 'editor_state.dart';
import 'package:flutter/material.dart';
class FlowyEditor extends StatefulWidget {
const FlowyEditor({
Key? key,
required this.editorState,
}) : super(key: key);
final EditorState editorState;
@override
State<FlowyEditor> createState() => _FlowyEditorState();
}
class _FlowyEditorState extends State<FlowyEditor> {
EditorState get editorState => widget.editorState;
@override
Widget build(BuildContext context) {
return FlowySelectionWidget(
editorState: editorState,
child: FlowyKeyboardWidget(
handlers: const [],
editorState: editorState,
child: editorState.build(context),
),
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:flutter/services.dart';
import 'editor_state.dart';
import 'package:flutter/material.dart';
abstract class FlowyKeyboardHandler {
final EditorState editorState;
final RawKeyEvent rawKeyEvent;
FlowyKeyboardHandler({
required this.editorState,
required this.rawKeyEvent,
});
KeyEventResult onKeyDown();
}
/// Process keyboard events
class FlowyKeyboardWidget extends StatefulWidget {
const FlowyKeyboardWidget({
Key? key,
required this.handlers,
required this.editorState,
required this.child,
}) : super(key: key);
final EditorState editorState;
final Widget child;
final List<FlowyKeyboardHandler> handlers;
@override
State<FlowyKeyboardWidget> createState() => _FlowyKeyboardWidgetState();
}
class _FlowyKeyboardWidgetState extends State<FlowyKeyboardWidget> {
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) {
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
}
for (final handler in widget.handlers) {
debugPrint('handle keyboard event $event by $handler');
KeyEventResult result = handler.onKeyDown();
switch (result) {
case KeyEventResult.handled:
return KeyEventResult.handled;
case KeyEventResult.skipRemainingHandlers:
return KeyEventResult.skipRemainingHandlers;
case KeyEventResult.ignored:
break;
}
}
return KeyEventResult.ignored;
}
}

View File

@ -0,0 +1,279 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'editor_state.dart';
import 'document/node.dart';
import '../render/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();
void updateCursor();
/// Returns selected node(s)
/// Returns empty list if no nodes are being selected.
List<Node> get selectedNodes;
/// Compute selected node triggered by [Tap]
Node? computeSelectedNodeByTap(
Node node,
Offset offset,
);
/// Compute selected nodes triggered by [Pan]
List<Node> computeSelectedNodesByPan(
Node node,
Offset start,
Offset end,
);
/// Pan
bool isNodeInSelection(
Node node,
Offset start,
Offset end,
);
/// Tap
bool isNodeInOffset(
Node node,
Offset offset,
);
}
class FlowySelectionWidget extends StatefulWidget {
const FlowySelectionWidget({
Key? key,
required this.editorState,
required this.child,
}) : super(key: key);
final EditorState editorState;
final Widget child;
@override
State<FlowySelectionWidget> createState() => _FlowySelectionWidgetState();
}
class _FlowySelectionWidgetState extends State<FlowySelectionWidget>
with _FlowySelectionService {
List<OverlayEntry> selectionOverlays = [];
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() {
_clearOverlay();
final nodes = selectedNodes;
if (nodes.isEmpty || panStartOffset == null || panEndOffset == null) {
assert(panStartOffset == null);
assert(panEndOffset == null);
return;
}
for (final node in nodes) {
final selectable = node.key?.currentState as Selectable?;
if (selectable != null) {
final selectionRects = selectable.getSelectionRectsInSelection(
panStartOffset!, panEndOffset!);
for (final rect in selectionRects) {
final overlay = OverlayEntry(
builder: ((context) => Positioned.fromRect(
rect: rect,
child: Container(
color: Colors.yellow.withAlpha(100),
),
)),
);
selectionOverlays.add(overlay);
}
}
}
Overlay.of(context)?.insertAll(selectionOverlays);
}
@override
void updateCursor() {
_clearOverlay();
if (tapOffset == null) {
assert(tapOffset == null);
return;
}
final nodes = selectedNodes;
if (nodes.isEmpty) {
return;
}
final selectedNode = nodes.first;
final selectable = selectedNode.key?.currentState as Selectable?;
if (selectable != null) {
final rect = selectable.getCursorRect(tapOffset!);
final cursor = OverlayEntry(
builder: ((context) => Positioned.fromRect(
rect: rect,
child: Container(
color: Colors.blue,
),
)),
);
selectionOverlays.add(cursor);
}
Overlay.of(context)?.insertAll(selectionOverlays);
}
@override
List<Node> get selectedNodes {
if (panStartOffset != null && panEndOffset != null) {
return computeSelectedNodesByPan(
editorState.document.root, panStartOffset!, panEndOffset!);
} else if (tapOffset != null) {
final reuslt =
computeSelectedNodeByTap(editorState.document.root, tapOffset!);
if (reuslt != null) {
return [reuslt];
}
}
return [];
}
@override
Node? computeSelectedNodeByTap(Node node, Offset offset) {
assert(this.tapOffset != null);
final tapOffset = this.tapOffset;
if (tapOffset != null) {}
if (node.parent != null && node.key != null) {
if (isNodeInOffset(node, offset)) {
return node;
}
}
for (final child in node.children) {
final result = computeSelectedNodeByTap(child, offset);
if (result != null) {
return result;
}
}
return null;
}
@override
List<Node> computeSelectedNodesByPan(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(computeSelectedNodesByPan(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();
}
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();
}
void _onPanEnd(DragEndDetails details) {
// do nothing
}
void _clearOverlay() {
selectionOverlays
..forEach((overlay) => overlay.remove())
..clear();
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/services.dart';
import '../render/selectable.dart';
import 'editor_state.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
class Keyboard extends StatelessWidget {

View File

@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
mixin Selectable<T extends StatefulWidget> on State<T> {
/// Returns a [Rect] list for overlay.
/// [start] and [end] are global offsets.
List<Rect> getOverlayRectsInRange(Offset start, Offset end);
List<Rect> getSelectionRectsInSelection(Offset start, Offset end);
/// Returns a [Offset] for cursor
/// Returns a [Rect] for cursor
Rect getCursorRect(Offset start);
}