Переглянути джерело

feat: implement input service(alpha)

Lucas.Xu 2 роки тому
батько
коміт
155b675dbe

+ 234 - 0
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<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,
+      ),
+    );
+  }
+}

+ 90 - 46
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<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(),
+    );
+  }
 }

+ 19 - 14
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<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),
+          ),
         ),
       ),
     );

+ 179 - 0
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<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();
+    }
+  }
+}

+ 12 - 5
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

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

+ 3 - 0
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');