feat: support floating selection and delete textnode

This commit is contained in:
Lucas.Xu 2022-07-25 14:14:40 +08:00
parent f58a6c9523
commit e1d990e4ae
13 changed files with 176 additions and 83 deletions

View File

@ -74,7 +74,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "1. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -83,7 +83,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "2. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -92,7 +92,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "3. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -101,7 +101,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "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." "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": {} "attributes": {}
@ -110,7 +110,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "5. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -119,7 +119,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "6. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -128,7 +128,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "7. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -137,7 +137,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "8. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -146,7 +146,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "9. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -155,7 +155,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "10. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}
@ -164,7 +164,7 @@
"type": "text", "type": "text",
"delta": [ "delta": [
{ {
"insert": "Click the '?' at the bottom right for help and support." "insert": "11. Click the '?' at the bottom right for help and support."
} }
], ],
"attributes": {} "attributes": {}

View File

@ -96,9 +96,7 @@ class _MyHomePageState extends State<MyHomePage> {
); );
return FlowyEditor( return FlowyEditor(
editorState: _editorState, editorState: _editorState,
keyEventHandler: [ keyEventHandler: const [],
deleteSingleImageNode,
],
); );
} }
}, },

View File

@ -1,17 +1,6 @@
import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
FlowyKeyEventHandler deleteSingleImageNode = (editorState, event) {
final selectNodes = editorState.selectedNodes;
if (selectNodes.length != 1 || selectNodes.first.type != 'image') {
return KeyEventResult.ignored;
}
TransactionBuilder(editorState)
..deleteNode(selectNodes.first)
..commit();
return KeyEventResult.handled;
};
class ImageNodeBuilder extends NodeWidgetBuilder { class ImageNodeBuilder extends NodeWidgetBuilder {
ImageNodeBuilder.create({ ImageNodeBuilder.create({
required super.node, required super.node,
@ -67,6 +56,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
return null; return null;
} }
@override
Offset getOffsetByTextSelection(TextSelection textSelection) {
return Offset.zero;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _build(context); return _build(context);

View File

@ -93,6 +93,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset; final selectionBaseOffset = _getTextPositionAtOffset(localStart).offset;
final textSelection = TextSelection.collapsed(offset: selectionBaseOffset); final textSelection = TextSelection.collapsed(offset: selectionBaseOffset);
_textSelection = textSelection; _textSelection = textSelection;
print('text selection = $textSelection');
return _computeCursorRect(textSelection.baseOffset); return _computeCursorRect(textSelection.baseOffset);
} }
@ -101,6 +102,12 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
return _textSelection; return _textSelection;
} }
@override
Offset getOffsetByTextSelection(TextSelection textSelection) {
final offset = _computeCursorRect(textSelection.baseOffset).center;
return _renderParagraph.localToGlobal(offset);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget richText; Widget richText;
@ -148,6 +155,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
final cursorOffset = final cursorOffset =
_renderParagraph.getOffsetForCaret(position, Rect.zero); _renderParagraph.getOffsetForCaret(position, Rect.zero);
final cursorHeight = _renderParagraph.getFullHeightForCaret(position); final cursorHeight = _renderParagraph.getFullHeightForCaret(position);
print('offset = $offset, cursorHeight = $cursorHeight');
if (cursorHeight != null) { if (cursorHeight != null) {
const cursorWidth = 2; const cursorWidth = 2;
return Rect.fromLTWH( return Rect.fromLTWH(

View File

@ -19,6 +19,9 @@ class ApplyOptions {
}); });
} }
// TODO
final selectionServiceKey = GlobalKey();
class EditorState { class EditorState {
final StateTree document; final StateTree document;
final RenderPlugins renderPlugins; final RenderPlugins renderPlugins;

View File

@ -0,0 +1,8 @@
extension FlowyObjectExtensions on Object {
T? unwrapOrNull<T>() {
if (this is T) {
return this as T;
}
return null;
}
}

View File

@ -11,5 +11,4 @@ export 'package:flowy_editor/operation/transaction.dart';
export 'package:flowy_editor/operation/transaction_builder.dart'; export 'package:flowy_editor/operation/transaction_builder.dart';
export 'package:flowy_editor/operation/operation.dart'; export 'package:flowy_editor/operation/operation.dart';
export 'package:flowy_editor/editor_state.dart'; export 'package:flowy_editor/editor_state.dart';
export 'package:flowy_editor/service/flowy_editor_service.dart'; export 'package:flowy_editor/service/editor_service.dart';
export 'package:flowy_editor/service/flowy_keyboard_service.dart';

View File

@ -13,4 +13,7 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
/// For [TextNode] only. /// For [TextNode] only.
TextSelection? getTextSelection(); TextSelection? getTextSelection();
/// For [TextNode] only.
Offset getOffsetByTextSelection(TextSelection textSelection);
} }

View File

@ -1,5 +1,7 @@
import 'package:flowy_editor/service/flowy_keyboard_service.dart'; import 'package:flowy_editor/service/flowy_key_event_handlers/delete_nodes_handler.dart';
import 'package:flowy_editor/service/flowy_selection_service.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 '../editor_state.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -23,11 +25,13 @@ class _FlowyEditorState extends State<FlowyEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlowySelectionService( return FlowySelection(
key: selectionServiceKey,
editorState: editorState, editorState: editorState,
child: FlowyKeyboardWidget( child: FlowyKeyboard(
handlers: [ handlers: [
flowyDeleteNodesHandler, flowyDeleteNodesHandler,
deleteSingleTextNodeHandler,
...widget.keyEventHandler, ...widget.keyEventHandler,
], ],
editorState: editorState, editorState: editorState,

View File

@ -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;
};

View File

@ -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;
};

View File

@ -1,4 +1,3 @@
import 'package:flowy_editor/operation/transaction_builder.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../editor_state.dart'; import '../editor_state.dart';
@ -9,27 +8,9 @@ typedef FlowyKeyEventHandler = KeyEventResult Function(
RawKeyEvent event, RawKeyEvent event,
); );
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;
};
/// Process keyboard events /// Process keyboard events
class FlowyKeyboardWidget extends StatefulWidget { class FlowyKeyboard extends StatefulWidget {
const FlowyKeyboardWidget({ const FlowyKeyboard({
Key? key, Key? key,
required this.handlers, required this.handlers,
required this.editorState, required this.editorState,
@ -41,10 +22,10 @@ class FlowyKeyboardWidget extends StatefulWidget {
final List<FlowyKeyEventHandler> handlers; final List<FlowyKeyEventHandler> handlers;
@override @override
State<FlowyKeyboardWidget> createState() => _FlowyKeyboardWidgetState(); State<FlowyKeyboard> createState() => _FlowyKeyboardState();
} }
class _FlowyKeyboardWidgetState extends State<FlowyKeyboardWidget> { class _FlowyKeyboardState extends State<FlowyKeyboard> {
final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service');
@override @override

View File

@ -8,7 +8,7 @@ import '../document/node.dart';
import '../render/selection/selectable.dart'; import '../render/selection/selectable.dart';
/// Process selection and cursor /// Process selection and cursor
mixin _FlowySelectionService<T extends StatefulWidget> on State<T> { mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// [Pan] and [Tap] must be mutually exclusive. /// [Pan] and [Tap] must be mutually exclusive.
/// Pan /// Pan
Offset? panStartOffset; Offset? panStartOffset;
@ -19,20 +19,20 @@ mixin _FlowySelectionService<T extends StatefulWidget> on State<T> {
void updateSelection(Offset start, Offset end); void updateSelection(Offset start, Offset end);
void updateCursor(Offset offset); void updateCursor(Offset start);
/// Returns selected node(s) /// Returns selected node(s)
/// Returns empty list if no nodes are being selected. /// Returns empty list if no nodes are being selected.
List<Node> get selectedNodes; List<Node> getSelectedNodes(Offset start, [Offset? end]);
/// Compute selected node triggered by [Tap] /// Compute selected node triggered by [Tap]
Node? computeSelectedNodeByTap( Node? computeSelectedNodeInOffset(
Node node, Node node,
Offset offset, Offset offset,
); );
/// Compute selected nodes triggered by [Pan] /// Compute selected nodes triggered by [Pan]
List<Node> computeSelectedNodesByPan( List<Node> computeSelectedNodesInRange(
Node node, Node node,
Offset start, Offset start,
Offset end, Offset end,
@ -52,8 +52,8 @@ mixin _FlowySelectionService<T extends StatefulWidget> on State<T> {
); );
} }
class FlowySelectionService extends StatefulWidget { class FlowySelection extends StatefulWidget {
const FlowySelectionService({ const FlowySelection({
Key? key, Key? key,
required this.editorState, required this.editorState,
required this.child, required this.child,
@ -63,11 +63,11 @@ class FlowySelectionService extends StatefulWidget {
final Widget child; final Widget child;
@override @override
State<FlowySelectionService> createState() => _FlowySelectionServiceState(); State<FlowySelection> createState() => _FlowySelectionState();
} }
class _FlowySelectionServiceState extends State<FlowySelectionService> class _FlowySelectionState extends State<FlowySelection>
with _FlowySelectionService { with FlowySelectionService {
final _cursorKey = GlobalKey(debugLabel: 'cursor'); final _cursorKey = GlobalKey(debugLabel: 'cursor');
final List<OverlayEntry> _selectionOverlays = []; final List<OverlayEntry> _selectionOverlays = [];
@ -106,7 +106,7 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
void updateSelection(Offset start, Offset end) { void updateSelection(Offset start, Offset end) {
_clearAllOverlayEntries(); _clearAllOverlayEntries();
final nodes = selectedNodes; final nodes = getSelectedNodes(start, end);
editorState.selectedNodes = nodes; editorState.selectedNodes = nodes;
if (nodes.isEmpty) { if (nodes.isEmpty) {
return; return;
@ -133,10 +133,10 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
} }
@override @override
void updateCursor(Offset offset) { void updateCursor(Offset start) {
_clearAllOverlayEntries(); _clearAllOverlayEntries();
final nodes = selectedNodes; final nodes = getSelectedNodes(start);
editorState.selectedNodes = nodes; editorState.selectedNodes = nodes;
if (nodes.isEmpty) { if (nodes.isEmpty) {
return; return;
@ -147,7 +147,7 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
return; return;
} }
final selectable = selectedNode.key?.currentState as Selectable; final selectable = selectedNode.key?.currentState as Selectable;
final rect = selectable.getCursorRect(offset); final rect = selectable.getCursorRect(start);
final cursor = OverlayEntry( final cursor = OverlayEntry(
builder: ((context) => FlowyCursorWidget( builder: ((context) => FlowyCursorWidget(
key: _cursorKey, key: _cursorKey,
@ -161,13 +161,18 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
} }
@override @override
List<Node> get selectedNodes { List<Node> getSelectedNodes(Offset start, [Offset? end]) {
if (panStartOffset != null && panEndOffset != null) { if (end != null) {
return computeSelectedNodesByPan( return computeSelectedNodesInRange(
editorState.document.root, panStartOffset!, panEndOffset!); editorState.document.root,
} else if (tapOffset != null) { start,
final reuslt = end,
computeSelectedNodeByTap(editorState.document.root, tapOffset!); );
} else {
final reuslt = computeSelectedNodeInOffset(
editorState.document.root,
start,
);
if (reuslt != null) { if (reuslt != null) {
return [reuslt]; return [reuslt];
} }
@ -176,13 +181,9 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
} }
@override @override
Node? computeSelectedNodeByTap(Node node, Offset offset) { Node? computeSelectedNodeInOffset(Node node, Offset offset) {
assert(this.tapOffset != null);
final tapOffset = this.tapOffset;
if (tapOffset != null) {}
for (final child in node.children) { for (final child in node.children) {
final result = computeSelectedNodeByTap(child, offset); final result = computeSelectedNodeInOffset(child, offset);
if (result != null) { if (result != null) {
return result; return result;
} }
@ -198,7 +199,7 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
} }
@override @override
List<Node> computeSelectedNodesByPan(Node node, Offset start, Offset end) { List<Node> computeSelectedNodesInRange(Node node, Offset start, Offset end) {
List<Node> result = []; List<Node> result = [];
if (node.parent != null && node.key != null) { if (node.parent != null && node.key != null) {
if (isNodeInSelection(node, start, end)) { if (isNodeInSelection(node, start, end)) {
@ -206,7 +207,7 @@ class _FlowySelectionServiceState extends State<FlowySelectionService>
} }
} }
for (final child in node.children) { for (final child in node.children) {
result.addAll(computeSelectedNodesByPan(child, start, end)); result.addAll(computeSelectedNodesInRange(child, start, end));
} }
// TODO: sort the result // TODO: sort the result
return result; return result;