diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart new file mode 100644 index 0000000000..01da3ab593 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart @@ -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 children; + + @override + State createState() => _ExpandableFabState(); +} + +class _ExpandableFabState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _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 _buildExpandingActionButtons() { + final children = []; + 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 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, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 112c1dcd4f..c64c50c090 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -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 { final RenderPlugins renderPlugins = RenderPlugins(); late EditorState _editorState; + int page = 0; @override void initState() { super.initState(); @@ -80,53 +82,95 @@ class _MyHomePageState extends State { // the App.build method, and use it to set our appbar title. title: Text(widget.title), ), - body: FutureBuilder( - future: rootBundle.loadString('assets/document.json'), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } else { - final data = Map.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( + future: rootBundle.loadString('assets/document.json'), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + final data = Map.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(), + ); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index 8b40981ccb..307b586f31 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -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 { 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), + ), ), ), ); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart new file mode 100644 index 0000000000..c02e9828e4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart @@ -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 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 createState() => _FlowyInputState(); +} + +class _FlowyInputState extends State + 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 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 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 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(); + final text = textNodes.fold( + '', (sum, textNode) => '$sum${textNode.toRawString()}\n'); + attach( + TextEditingValue( + text: text, + selection: TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset, + ), + ), + ); + } else { + close(); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 19604b0227..be3fde4062 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -17,7 +17,8 @@ mixin FlowySelectionService on State { /// Returns the currently selected [Node]s. /// /// The order of the return is determined according to the selected order. - List get currentSelectedNodes; + ValueNotifier> get currentSelectedNodes; + Selection? get currentSelection; /// ------------------ Selection ------------------------ @@ -112,7 +113,10 @@ class _FlowySelectionState extends State EditorState get editorState => widget.editorState; @override - List currentSelectedNodes = []; + Selection? currentSelection; + + @override + ValueNotifier> currentSelectedNodes = ValueNotifier([]); @override List getNodesInSelection(Selection selection) => @@ -292,7 +296,8 @@ class _FlowySelectionState extends State } void _clearSelection() { - currentSelectedNodes = []; + currentSelection = null; + currentSelectedNodes.value = []; // clear selection _selectionOverlays @@ -312,7 +317,8 @@ class _FlowySelectionState extends State 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 return; } - currentSelectedNodes = [node]; + currentSelection = Selection.collapsed(position); + currentSelectedNodes.value = [node]; final selectable = node.selectable; final rect = selectable?.getCursorRectInPosition(position); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart index f8cf4a9e5c..8fe715bbe7 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart @@ -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');