Merge pull request #709 from LucasXu0/feat/flowy_editor

feat: Implement arrow up/down/left/right event handler. #708
This commit is contained in:
Nathan.fooo 2022-07-26 11:54:44 +08:00 committed by GitHub
commit b967453047
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 356 additions and 33 deletions

View File

@ -97,6 +97,32 @@ class _MyHomePageState extends State<MyHomePage> {
return FlowyEditor(
editorState: _editorState,
keyEventHandler: const [],
shortcuts: [
// TODO: this won't work, just a example for now.
{
'h1': (editorState, eventName) {
debugPrint('shortcut => $eventName');
final selectedNodes = editorState.selectedNodes;
if (selectedNodes.isEmpty) {
return;
}
final textNode = selectedNodes.first as TextNode;
TransactionBuilder(editorState)
..formatText(textNode, 0, textNode.toRawString().length, {
'heading': 'h1',
})
..commit();
}
},
{
'bold': (editorState, eventName) =>
debugPrint('shortcut => $eventName')
},
{
'underline': (editorState, eventName) =>
debugPrint('shortcut => $eventName')
},
],
);
}
},

View File

@ -52,7 +52,7 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
}
@override
TextSelection? getTextSelection() {
TextSelection? getCurrentTextSelection() {
return null;
}
@ -61,6 +61,16 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
return Offset.zero;
}
@override
Offset getBackwardOffset() {
return Offset.zero;
}
@override
Offset getForwardOffset() {
return Offset.zero;
}
@override
Widget build(BuildContext context) {
return _build(context);

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:example/plugin/debuggable_rich_text.dart';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/foundation.dart';
@ -98,7 +100,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
}
@override
TextSelection? getTextSelection() {
TextSelection? getCurrentTextSelection() {
return _textSelection;
}
@ -108,6 +110,30 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
return _renderParagraph.localToGlobal(offset);
}
@override
Offset getBackwardOffset() {
final textSelection = _textSelection;
if (textSelection != null) {
final leftTextSelection = TextSelection.collapsed(
offset: max(0, textSelection.baseOffset - 1),
);
return getOffsetByTextSelection(leftTextSelection);
}
return Offset.zero;
}
@override
Offset getForwardOffset() {
final textSelection = _textSelection;
if (textSelection != null) {
final leftTextSelection = TextSelection.collapsed(
offset: min(node.toRawString().length, textSelection.extentOffset + 1),
);
return getOffsetByTextSelection(leftTextSelection);
}
return Offset.zero;
}
@override
Widget build(BuildContext context) {
Widget richText;
@ -117,6 +143,10 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
richText = RichText(key: _textKey, text: node.toTextSpan());
}
if (node.children.isEmpty) {
return richText;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flowy_editor/service/service.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/document/node.dart';
@ -21,13 +22,14 @@ class ApplyOptions {
});
}
// TODO
final selectionServiceKey = GlobalKey();
class EditorState {
final StateTree document;
final RenderPlugins renderPlugins;
List<Node> selectedNodes = [];
// Service reference.
final service = FlowyService();
final UndoManager undoManager = UndoManager();
Selection? cursorSelection;

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
class FlowyCursorWidget extends StatefulWidget {
const FlowyCursorWidget({
class CursorWidget extends StatefulWidget {
const CursorWidget({
Key? key,
required this.layerLink,
required this.rect,
@ -17,10 +17,10 @@ class FlowyCursorWidget extends StatefulWidget {
final LayerLink layerLink;
@override
State<FlowyCursorWidget> createState() => _FlowyCursorWidgetState();
State<CursorWidget> createState() => _CursorWidgetState();
}
class _FlowyCursorWidgetState extends State<FlowyCursorWidget> {
class _CursorWidgetState extends State<CursorWidget> {
bool showCursor = true;
late Timer timer;

View File

@ -0,0 +1,58 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart';
typedef FloatingShortcutHandler = void Function(
EditorState editorState, String eventName);
typedef FloatingShortcuts = List<Map<String, FloatingShortcutHandler>>;
class FloatingShortcutWidget extends StatelessWidget {
const FloatingShortcutWidget({
Key? key,
required this.editorState,
required this.layerLink,
required this.rect,
required this.floatingShortcuts,
}) : super(key: key);
final EditorState editorState;
final LayerLink layerLink;
final Rect rect;
final FloatingShortcuts floatingShortcuts;
List<String> get _shortcutNames =>
floatingShortcuts.map((shortcut) => shortcut.keys.first).toList();
List<FloatingShortcutHandler> get _shortcutHandlers =>
floatingShortcuts.map((shortcut) => shortcut.values.first).toList();
@override
Widget build(BuildContext context) {
return Positioned.fromRect(
rect: rect,
child: CompositedTransformFollower(
link: layerLink,
offset: rect.topLeft,
showWhenUnlinked: true,
child: Container(
color: Colors.white,
child: ListView.builder(
itemCount: floatingShortcuts.length,
itemBuilder: ((context, index) {
final name = _shortcutNameInIndex(index);
final handler = _shortcutHandlerInIndex(index);
return Card(
child: GestureDetector(
onTap: () => handler(editorState, name),
child: ListTile(title: Text(name)),
),
);
}),
),
),
),
);
}
String _shortcutNameInIndex(int index) => _shortcutNames[index];
FloatingShortcutHandler _shortcutHandlerInIndex(int index) =>
_shortcutHandlers[index];
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
class FlowySelectionWidget extends StatefulWidget {
const FlowySelectionWidget({
class SelectionWidget extends StatefulWidget {
const SelectionWidget({
Key? key,
required this.layerLink,
required this.rect,
@ -13,10 +13,10 @@ class FlowySelectionWidget extends StatefulWidget {
final LayerLink layerLink;
@override
State<FlowySelectionWidget> createState() => _FlowySelectionWidgetState();
State<SelectionWidget> createState() => _SelectionWidgetState();
}
class _FlowySelectionWidgetState extends State<FlowySelectionWidget> {
class _SelectionWidgetState extends State<SelectionWidget> {
@override
Widget build(BuildContext context) {
return Positioned.fromRect(

View File

@ -2,18 +2,40 @@ 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.
/// The return result must be an local offset.
/// Returns a [List] of the [Rect] selection sorrounded by start and end
/// in current widget.
///
/// [start] and [end] are the offsets under the global coordinate system.
///
/// The return result must be a [List] of the [Rect]
/// under the local coordinate system.
List<Rect> getSelectionRectsInRange(Offset start, Offset end);
/// Returns a [Rect] for cursor.
/// The return result must be an local offset.
/// Returns a [Rect] for the offset in current widget.
///
/// [start] is the offset of the global coordination system.
///
/// The return result must be an offset of the local coordinate system.
Rect getCursorRect(Offset start);
/// For [TextNode] only.
TextSelection? getTextSelection();
/// Returns a backward offset of the current offset based on the cause.
Offset getBackwardOffset(/* Cause */);
/// Returns a forward offset of the current offset based on the cause.
Offset getForwardOffset(/* Cause */);
/// For [TextNode] only.
///
/// Returns a [TextSelection] or [Null].
///
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null.
TextSelection? getCurrentTextSelection();
/// For [TextNode] only.
///
/// Retruns a [Offset].
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return [Offset.zero].
Offset getOffsetByTextSelection(TextSelection textSelection);
}

View File

@ -1,5 +1,9 @@
import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
import 'package:flowy_editor/service/floating_shortcut_service.dart';
import 'package:flowy_editor/service/flowy_key_event_handlers/arrow_keys_handler.dart';
import 'package:flowy_editor/service/flowy_key_event_handlers/delete_nodes_handler.dart';
import 'package:flowy_editor/service/flowy_key_event_handlers/delete_single_text_node_handler.dart';
import 'package:flowy_editor/service/flowy_key_event_handlers/shortcut_handler.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flowy_editor/service/selection_service.dart';
@ -11,10 +15,12 @@ class FlowyEditor extends StatefulWidget {
Key? key,
required this.editorState,
required this.keyEventHandler,
required this.shortcuts,
}) : super(key: key);
final EditorState editorState;
final List<FlowyKeyEventHandler> keyEventHandler;
final FloatingShortcuts shortcuts;
@override
State<FlowyEditor> createState() => _FlowyEditorState();
@ -26,16 +32,25 @@ class _FlowyEditorState extends State<FlowyEditor> {
@override
Widget build(BuildContext context) {
return FlowySelection(
key: selectionServiceKey,
key: editorState.service.selectionServiceKey,
editorState: editorState,
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
slashShortcutHandler,
flowyDeleteNodesHandler,
deleteSingleTextNodeHandler,
arrowKeysHandler,
...widget.keyEventHandler,
],
editorState: editorState,
child: editorState.build(context),
child: FloatingShortcut(
key: editorState.service.floatingShortcutServiceKey,
size: const Size(200, 150), // TODO: support customize size.
editorState: editorState,
floatingShortcuts: widget.shortcuts,
child: editorState.build(context),
),
),
);
}

View File

@ -0,0 +1,60 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
import 'package:flutter/material.dart';
mixin FlowyFloatingShortcutService {
/// Show the floating shortcut widget beside the offset.
void showInOffset(Offset offset, LayerLink layerLink);
/// Hide the floating shortcut widget.
void hide();
}
class FloatingShortcut extends StatefulWidget {
const FloatingShortcut({
Key? key,
required this.size,
required this.editorState,
required this.floatingShortcuts,
required this.child,
}) : super(key: key);
final Size size;
final EditorState editorState;
final Widget child;
final FloatingShortcuts floatingShortcuts;
@override
State<FloatingShortcut> createState() => _FloatingShortcutState();
}
class _FloatingShortcutState extends State<FloatingShortcut>
with FlowyFloatingShortcutService {
OverlayEntry? _floatintShortcutOverlay;
@override
void showInOffset(Offset offset, LayerLink layerLink) {
_floatintShortcutOverlay?.remove();
_floatintShortcutOverlay = OverlayEntry(
builder: (context) => FloatingShortcutWidget(
editorState: widget.editorState,
layerLink: layerLink,
rect: offset.translate(10, 0) & widget.size,
floatingShortcuts: widget.floatingShortcuts),
);
Overlay.of(context)?.insert(_floatintShortcutOverlay!);
}
@override
void hide() {
_floatintShortcutOverlay?.remove();
_floatintShortcutOverlay = null;
}
@override
Widget build(BuildContext context) {
return Container(
child: widget.child,
);
}
}

View File

@ -0,0 +1,37 @@
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
event.logicalKey != LogicalKeyboardKey.arrowDown &&
event.logicalKey != LogicalKeyboardKey.arrowLeft &&
event.logicalKey != LogicalKeyboardKey.arrowRight) {
return KeyEventResult.ignored;
}
// TODO: Up and Down
// Left and Right
final selectedNodes = editorState.selectedNodes;
if (selectedNodes.length != 1) {
return KeyEventResult.ignored;
}
final node = selectedNodes.first.unwrapOrNull<TextNode>();
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
Offset? offset;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
offset = selectable?.getBackwardOffset();
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
offset = selectable?.getForwardOffset();
}
final selectionService = editorState.service.selectionService;
if (offset != null) {
selectionService.updateCursor(offset);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};

View File

@ -1,10 +1,8 @@
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';
@ -19,7 +17,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
final node = selectionNodes.first.unwrapOrNull<TextNode>();
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
if (selectable != null) {
final textSelection = selectable.getTextSelection();
final textSelection = selectable.getCurrentTextSelection();
if (textSelection != null) {
if (textSelection.isCollapsed) {
/// Three cases:
@ -33,8 +31,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
final previous = node!.previous! as TextNode;
final newTextSelection = TextSelection.collapsed(
offset: previous.toRawString().length);
final selectionService =
selectionServiceKey.currentState as FlowySelectionService;
final selectionService = editorState.service.selectionService;
final previousSelectable =
previous.key?.currentState?.unwrapOrNull<Selectable>();
final newOfset = previousSelectable
@ -58,8 +55,7 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
..commit();
final newTextSelection =
TextSelection.collapsed(offset: textSelection.baseOffset - 1);
final selectionService =
selectionServiceKey.currentState as FlowySelectionService;
final selectionService = editorState.service.selectionService;
final newOfset =
selectable.getOffsetByTextSelection(newTextSelection);
selectionService.updateCursor(newOfset);

View File

@ -0,0 +1,30 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// type '/' to trigger shortcut widget
FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.slash) {
return KeyEventResult.ignored;
}
final selectedNodes = editorState.selectedNodes;
if (selectedNodes.length != 1) {
return KeyEventResult.ignored;
}
final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
final textSelection = selectable?.getCurrentTextSelection();
if (textNode != null && selectable != null && textSelection != null) {
final offset = selectable.getOffsetByTextSelection(textSelection);
final rect = selectable.getCursorRect(offset);
editorState.service.floatingToolbarService
.showInOffset(rect.topLeft, textNode.layerLink);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};

View File

@ -46,7 +46,7 @@ class _FlowyKeyboardState extends State<FlowyKeyboard> {
}
for (final handler in widget.handlers) {
debugPrint('handle keyboard event $event by $handler');
// debugPrint('handle keyboard event $event by $handler');
KeyEventResult result = handler(widget.editorState, event);

View File

@ -1,5 +1,7 @@
import 'package:flowy_editor/render/selection/flowy_cursor_widget.dart';
import 'package:flowy_editor/render/selection/cursor_widget.dart';
import 'package:flowy_editor/render/selection/flowy_selection_widget.dart';
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flowy_editor/service/floating_shortcut_service.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -120,7 +122,7 @@ class _FlowySelectionState extends State<FlowySelection>
final selectionRects = selectable.getSelectionRectsInRange(start, end);
for (final rect in selectionRects) {
final overlay = OverlayEntry(
builder: ((context) => FlowySelectionWidget(
builder: ((context) => SelectionWidget(
color: Colors.yellow.withAlpha(100),
layerLink: node.layerLink,
rect: rect,
@ -149,7 +151,7 @@ class _FlowySelectionState extends State<FlowySelection>
final selectable = selectedNode.key?.currentState as Selectable;
final rect = selectable.getCursorRect(start);
final cursor = OverlayEntry(
builder: ((context) => FlowyCursorWidget(
builder: ((context) => CursorWidget(
key: _cursorKey,
rect: rect,
color: Colors.red,
@ -275,6 +277,7 @@ class _FlowySelectionState extends State<FlowySelection>
void _clearAllOverlayEntries() {
_clearSelection();
_clearCursor();
_clearFloatingShorts();
}
void _clearSelection() {
@ -288,4 +291,11 @@ class _FlowySelectionState extends State<FlowySelection>
..forEach((overlay) => overlay.remove())
..clear();
}
void _clearFloatingShorts() {
final shortcutService = editorState
.service.floatingShortcutServiceKey.currentState
?.unwrapOrNull<FlowyFloatingShortcutService>();
shortcutService?.hide();
}
}

View File

@ -0,0 +1,27 @@
import 'package:flowy_editor/service/floating_shortcut_service.dart';
import 'package:flowy_editor/service/selection_service.dart';
import 'package:flutter/material.dart';
class FlowyService {
// selection service
final selectionServiceKey = GlobalKey(debugLabel: 'flowy_selection_service');
FlowySelectionService get selectionService {
assert(selectionServiceKey.currentState != null &&
selectionServiceKey.currentState is FlowySelectionService);
return selectionServiceKey.currentState! as FlowySelectionService;
}
// keyboard service
final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service');
// floating shortcut service
final floatingShortcutServiceKey =
GlobalKey(debugLabel: 'flowy_floating_shortcut_service');
FlowyFloatingShortcutService get floatingToolbarService {
assert(floatingShortcutServiceKey.currentState != null &&
floatingShortcutServiceKey.currentState
is FlowyFloatingShortcutService);
return floatingShortcutServiceKey.currentState!
as FlowyFloatingShortcutService;
}
}