superlistapp / super_editor

A Flutter toolkit for building document editors and readers
https://superlist.com/SuperEditor/
MIT License
1.69k stars 247 forks source link

Keyboard closes on tap outside of super editor in Mobile Web #1979

Open hawkkiller opened 6 months ago

hawkkiller commented 6 months ago

Package Version Github, main

To Reproduce Steps to reproduce the behavior:

  1. Run code sample in mobile browser (I tested on iOS)
  2. Focus editor, so that the keyboard is opened
  3. Tap the toggle button
  4. See that the keyboard closes

Minimal Reproduction Code

Minimal, Runnable Code Sample ```dart import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; void main() { runApp(const MainApp()); } class MainApp extends StatefulWidget { const MainApp({super.key}); @override State createState() => _MainAppState(); } class _MainAppState extends State { late final MutableDocument document; late final Editor editor; late final MutableDocumentComposer composer; bool selected1 = false; bool selected2 = false; bool selected3 = false; @override void initState() { super.initState(); document = MutableDocument.empty(); composer = MutableDocumentComposer(); editor = createDefaultDocumentEditor(document: document, composer: composer); } @override Widget build(BuildContext context) { return MaterialApp( home: Builder(builder: (context) { return Scaffold( body: Padding( padding: MediaQuery.of(context).padding, child: CustomScrollView( slivers: [ SliverList.list( children: [ ToggleButtons( onPressed: (index) {}, isSelected: const [false, false, false], children: const [ Icon(Icons.ac_unit), Icon(Icons.call), Icon(Icons.cake), ], ), const SizedBox(height: 400), ], ), SliverToBoxAdapter( child: SuperEditor( editor: editor, document: document, composer: composer, ), ) ], ), ), ); }), ); } } ```

Actual behavior Keyboard closes.

Expected behavior The keyboard is not closed.

Platform iOS Web.

Additional context In flutter docs, there is a TextFieldTapRegion that seems to work with text fields (though with some problems).

I have tried wrapping toggle buttons in TapRegion and providing the same group id to SuperEditor, but it doesn't seem to work.

cc: @matthew-carroll @angelosilvestre

matthew-carroll commented 6 months ago

At first glance, this doesn't look like a SuperEditor issue - it looks like a focus management issue.

The focused widget receives key events and IME input, so Super Editor can't keep the keyboard open if focus moves to a different widget. My guess is that tapping on the toggle button is changing focus to that button.

SuperEditor lets you specify a focusNode. It also lets you specify a tagRegionGroupId. Can you please try to use the available focus-related properties on SuperEditor to get the result that you want?

@angelosilvestre do you have any other thoughts on this?

hawkkiller commented 6 months ago

It is not necessarily a button. Tapping on any surface in Mobile Web closes the keyboard (material TextField as well). Another problem is that the SuperEditor stays focused (or at least paints the cursor).

https://github.com/superlistapp/super_editor/assets/62852417/064ebb76-2666-4bf7-9c91-0023843c18e6

matthew-carroll commented 6 months ago

Tapping on any surface in Mobile Web closes the keyboard (material TextField as well)

Right. I didn't say it was about the button. I said it was about the focus node that's probably inside the button, which is probably receiving focus when you tap on it.

Another problem is that the SuperEditor stays focused (or at least paints the cursor).

This may or may not be a bug. Depends on the exact situation.

angelosilvestre commented 6 months ago

By default, SuperEditor will close the IME connection when it loses focus. This will cause the keyboard to close.

If you want to keep the keyboard visible, you need to either:

matthew-carroll commented 6 months ago

@hawkkiller please let us know if Angelo's message resolved your issues.

hawkkiller commented 6 months ago

Hi @matthew-carroll @angelosilvestre

I've just verified that neither setting tap regions, focus nodes, or configuring the policy doesn't work (at least for the code provided).

Do you have any suggestions to check?

Widget build(BuildContext context) => MaterialApp(
        home: Builder(builder: (context) {
          return Scaffold(
            body: Padding(
              padding: MediaQuery.of(context).padding,
              child: Focus(
                focusNode: _focusNode,
                child: CustomScrollView(
                  slivers: [
                    SliverToBoxAdapter(
                      child: Center(
                        child: TapRegion(
                          groupId: 'super_editor',
                          child: ToggleButtons(
                            onPressed: (index) {},
                            isSelected: const [false, false, false],
                            children: const [
                              Icon(Icons.ac_unit),
                              Icon(Icons.call),
                              Icon(Icons.cake),
                            ],
                          ),
                        ),
                      ),
                    ),
                    SliverToBoxAdapter(
                      child: SuperEditor(
                        imePolicies: const SuperEditorImePolicies(
                          closeKeyboardOnSelectionLost: false,
                          closeImeOnNonPrimaryFocusLost: false,
                          closeKeyboardOnLosePrimaryFocus: false,
                        ),
                        tapRegionGroupId: 'super_editor',
                        focusNode: _focusNode,
                        editor: editor,
                        document: document,
                        composer: composer,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          );
        }),
      );
angelosilvestre commented 6 months ago

@hawkkiller Please try to do something like this:

Widget build(BuildContext context) => MaterialApp(
    home: Builder(builder: (context) {
      return Scaffold(
        body: Padding(
          padding: MediaQuery.of(context).padding,
          child: CustomScrollView(
            slivers: [
              SliverToBoxAdapter(
                child: Center(
                  child: Focus(
                    focusNode: _buttonsFocusNode,
                    parentNode: _editorFocusNode,
                    child: ToggleButtons(
                      onPressed: (index) {},
                      isSelected: const [false, false, false],
                      children: const [
                        Icon(Icons.ac_unit),
                        Icon(Icons.call),
                        Icon(Icons.cake),
                      ],
                    ),
                  ),
                ),
              ),
              SliverToBoxAdapter(
                child: SuperEditor(                                         
                  focusNode: _editorFocusNode,
                  editor: editor,
                  document: document,
                  composer: composer,
                ),
              ),
            ],
          ),
        ),
      );
    }),
  );
hawkkiller commented 6 months ago

@angelosilvestre Keyboard still closes and this happens only on the Mobile Web (where clicking anywhere closes the keyboard by default for some reason, which is not typical for other platforms).

For example, this issue doesn't occur on mobile (if launched not via Web).

hawkkiller commented 6 months ago

If you need assistance with debugging, testing, or experimenting with potential fixes for this problem, I'm happy to help you.

angelosilvestre commented 6 months ago

@hawkkiller Did you try to use my sample code and also provide the IME policies?

hawkkiller commented 6 months ago

@angelosilvestre Yes!

angelosilvestre commented 5 months ago

@matthew-carroll @hawkkiller I'm afraid we might not be able to work around this. I took a look and this seems related to the events the browser send us.

When we tap outside of the currently focused input ( the invisible input that Flutter places to handle the input), the browser sends us a blur event.

The Flutter engine listens for blur events and closes the input connection when that happens:

    // Record start time of blur subscription.
    final Stopwatch blurWatch = Stopwatch()..start();

    // On iOS, blur is trigerred in the following cases:
    //
    // 1. The browser app is sent to the background (or the tab is changed). In
    //    this case, the window loses focus (see [windowHasFocus]),
    //    so we close the input connection with the framework.
    // 2. The user taps on another focusable element. In this case, we refocus
    //    the input field and wait for the framework to manage the focus change.
    // 3. The virtual keyboard is closed by tapping "done". We can't detect this
    //    programmatically, so we end up refocusing the input field. This is
    //    okay because the virtual keyboard will hide, and as soon as the user
    //    taps the text field again, the virtual keyboard will come up.
    // 4. Safari sometimes sends a blur event immediately after activating the
    //    input field. In this case, we want to keep the focus on the input field.
    //    In order to detect this, we measure how much time has passed since the
    //    input field was activated. If the time is too short, we re-focus the
    //    input element.
    subscriptions.add(DomSubscription(activeDomElement, 'blur',
            (_) {
              final bool isFastCallback = blurWatch.elapsed < _blurFastCallbackInterval;
              if (windowHasFocus && isFastCallback) {
                activeDomElement.focus();
              } else {
                owner.sendTextConnectionClosedToFrameworkIfAny(); <----- Here
              }
            }));
  }

Maybe this comment is outdated, or I'm not looking at the correct place, but I'm not seeing how the following case is being handled:

// 2. The user taps on another focusable element. In this case, we refocus
//    the input field and wait for the framework to manage the focus change.
matthew-carroll commented 5 months ago

@justinmc @Renzo-Olivares - Do either of you have insights or info about this situation with Flutter on web when tapping outside the currently focused widget?

Renzo-Olivares commented 5 months ago

I'm not entirely sure what the issue is here but it looks like the frameworks TextField is exhibiting the same behavior. I opened an issue to track this https://github.com/flutter/flutter/issues/149685.