feat: implement input service(alpha)

This commit is contained in:
Lucas.Xu 2022-07-27 10:56:30 +08:00
parent c048c8f623
commit 155b675dbe
6 changed files with 537 additions and 65 deletions

View File

@ -0,0 +1,234 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab
@immutable
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
super.key,
this.initialOpen,
required this.distance,
required this.children,
});
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56.0,
height: 56.0,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4.0,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.create),
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({
super.key,
this.onPressed,
required this.icon,
});
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4.0,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}
@immutable
class FakeItem extends StatelessWidget {
const FakeItem({
super.key,
required this.isBig,
});
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
height: isBig ? 128.0 : 36.0,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.shade300,
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:example/expandable_floating_action_button.dart';
import 'package:example/plugin/document_node_widget.dart';
import 'package:example/plugin/selected_text_node_widget.dart';
import 'package:example/plugin/text_with_heading_node_widget.dart';
@ -60,6 +61,7 @@ class MyHomePage extends StatefulWidget {
class _MyHomePageState extends State<MyHomePage> {
final RenderPlugins renderPlugins = RenderPlugins();
late EditorState _editorState;
int page = 0;
@override
void initState() {
super.initState();
@ -80,53 +82,95 @@ class _MyHomePageState extends State<MyHomePage> {
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: FutureBuilder<String>(
future: rootBundle.loadString('assets/document.json'),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
final data = Map<String, Object>.from(json.decode(snapshot.data!));
final document = StateTree.fromJson(data);
_editorState = EditorState(
document: document,
renderPlugins: renderPlugins,
);
return FlowyEditor(
editorState: _editorState,
keyEventHandlers: 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')
},
],
);
}
},
body: _buildBody(),
floatingActionButton: ExpandableFab(
distance: 112.0,
children: [
ActionButton(
onPressed: () {
if (page == 0) return;
setState(() {
page = 0;
});
},
icon: const Icon(Icons.note_add),
),
ActionButton(
onPressed: () {
if (page == 1) return;
setState(() {
page = 1;
});
},
icon: const Icon(Icons.text_fields),
),
],
),
);
}
Widget _buildBody() {
if (page == 0) {
return _buildFlowyEditor();
} else if (page == 1) {
return _buildTextfield();
}
return Container();
}
Widget _buildFlowyEditor() {
return FutureBuilder<String>(
future: rootBundle.loadString('assets/document.json'),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
final data = Map<String, Object>.from(json.decode(snapshot.data!));
final document = StateTree.fromJson(data);
_editorState = EditorState(
document: document,
renderPlugins: renderPlugins,
);
return FlowyEditor(
editorState: _editorState,
keyEventHandlers: 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')
},
],
);
}
},
);
}
Widget _buildTextfield() {
return const Center(
child: TextField(),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
import 'package:flowy_editor/service/input_service.dart';
import 'package:flowy_editor/service/shortcut_service.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
@ -36,22 +37,26 @@ class _FlowyEditorState extends State<FlowyEditor> {
return FlowySelection(
key: editorState.service.selectionServiceKey,
editorState: editorState,
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
slashShortcutHandler,
flowyDeleteNodesHandler,
deleteSingleTextNodeHandler,
arrowKeysHandler,
...widget.keyEventHandlers,
],
child: FlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
child: FloatingShortcut(
key: editorState.service.floatingShortcutServiceKey,
size: const Size(200, 150), // TODO: support customize size.
child: FlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
slashShortcutHandler,
flowyDeleteNodesHandler,
deleteSingleTextNodeHandler,
arrowKeysHandler,
...widget.keyEventHandlers,
],
editorState: editorState,
floatingShortcuts: widget.shortcuts,
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,179 @@
import 'package:flowy_editor/editor_state.dart';
import 'package:flowy_editor/document/node.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
mixin FlowyInputService {
void attach(TextEditingValue textEditingValue);
void setTextEditingValue(TextEditingValue textEditingValue);
void apply(List<TextEditingDelta> deltas);
void close();
}
/// process input
class FlowyInput extends StatefulWidget {
const FlowyInput({
Key? key,
required this.editorState,
required this.child,
}) : super(key: key);
final EditorState editorState;
final Widget child;
@override
State<FlowyInput> createState() => _FlowyInputState();
}
class _FlowyInputState extends State<FlowyInput>
with FlowyInputService
implements DeltaTextInputClient {
TextInputConnection? _textInputConnection;
EditorState get _editorState => widget.editorState;
@override
void initState() {
super.initState();
_editorState.service.selectionService.currentSelectedNodes
.addListener(_onSelectedNodesChange);
}
@override
void dispose() {
_editorState.service.selectionService.currentSelectedNodes
.removeListener(_onSelectedNodesChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
child: widget.child,
);
}
@override
void attach(TextEditingValue textEditingValue) {
if (_textInputConnection != null) {
return;
}
_textInputConnection = TextInput.attach(
this,
const TextInputConfiguration(
// TODO: customize
enableDeltaModel: true,
inputType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
),
);
_textInputConnection
?..show()
..setEditingState(textEditingValue);
}
@override
void setTextEditingValue(TextEditingValue textEditingValue) {
assert(_textInputConnection != null,
'Must call `attach` before set textEditingValue');
if (_textInputConnection != null) {
_textInputConnection?.setEditingState(textEditingValue);
}
}
@override
void apply(List<TextEditingDelta> deltas) {}
@override
void close() {
_textInputConnection?.close();
_textInputConnection = null;
}
@override
void connectionClosed() {
// TODO: implement connectionClosed
}
@override
// TODO: implement currentAutofillScope
AutofillScope? get currentAutofillScope => throw UnimplementedError();
@override
// TODO: implement currentTextEditingValue
TextEditingValue? get currentTextEditingValue => throw UnimplementedError();
@override
void insertTextPlaceholder(Size size) {
// TODO: implement insertTextPlaceholder
}
@override
void performAction(TextInputAction action) {
// TODO: implement performAction
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// TODO: implement performPrivateCommand
}
@override
void removeTextPlaceholder() {
// TODO: implement removeTextPlaceholder
}
@override
void showAutocorrectionPromptRect(int start, int end) {
// TODO: implement showAutocorrectionPromptRect
}
@override
void showToolbar() {
// TODO: implement showToolbar
}
@override
void updateEditingValue(TextEditingValue value) {
// TODO: implement updateEditingValue
}
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
debugPrint(textEditingDeltas.map((delta) => delta.toString()).toString());
apply(textEditingDeltas);
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
// TODO: implement updateFloatingCursor
}
void _onSelectedNodesChange() {
final nodes =
_editorState.service.selectionService.currentSelectedNodes.value;
final selection = _editorState.service.selectionService.currentSelection;
// FIXME: upward.
if (nodes.isNotEmpty && selection != null) {
final textNodes = nodes.whereType<TextNode>();
final text = textNodes.fold<String>(
'', (sum, textNode) => '$sum${textNode.toRawString()}\n');
attach(
TextEditingValue(
text: text,
selection: TextSelection(
baseOffset: selection.start.offset,
extentOffset: selection.end.offset,
),
),
);
} else {
close();
}
}
}

View File

@ -17,7 +17,8 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
/// Returns the currently selected [Node]s.
///
/// The order of the return is determined according to the selected order.
List<Node> get currentSelectedNodes;
ValueNotifier<List<Node>> get currentSelectedNodes;
Selection? get currentSelection;
/// ------------------ Selection ------------------------
@ -112,7 +113,10 @@ class _FlowySelectionState extends State<FlowySelection>
EditorState get editorState => widget.editorState;
@override
List<Node> currentSelectedNodes = [];
Selection? currentSelection;
@override
ValueNotifier<List<Node>> currentSelectedNodes = ValueNotifier([]);
@override
List<Node> getNodesInSelection(Selection selection) =>
@ -292,7 +296,8 @@ class _FlowySelectionState extends State<FlowySelection>
}
void _clearSelection() {
currentSelectedNodes = [];
currentSelection = null;
currentSelectedNodes.value = [];
// clear selection
_selectionOverlays
@ -312,7 +317,8 @@ class _FlowySelectionState extends State<FlowySelection>
final nodes =
_selectedNodesInSelection(editorState.document.root, selection);
currentSelectedNodes = nodes;
currentSelection = selection;
currentSelectedNodes.value = nodes;
var index = 0;
for (final node in nodes) {
@ -374,7 +380,8 @@ class _FlowySelectionState extends State<FlowySelection>
return;
}
currentSelectedNodes = [node];
currentSelection = Selection.collapsed(position);
currentSelectedNodes.value = [node];
final selectable = node.selectable;
final rect = selectable?.getCursorRectInPosition(position);

View File

@ -14,6 +14,9 @@ class FlowyService {
// keyboard service
final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service');
// input service
final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service');
// floating shortcut service
final floatingShortcutServiceKey =
GlobalKey(debugLabel: 'flowy_floating_shortcut_service');