mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: abstract selection and keyboard from editor state
This commit is contained in:
parent
eb97141859
commit
a831ddc589
@ -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": [
|
||||
|
@ -94,7 +94,9 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
document: document,
|
||||
renderPlugins: renderPlugins,
|
||||
);
|
||||
return _editorState.build(context);
|
||||
return FlowyEditor(
|
||||
editorState: _editorState,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user