fix(app): blocks from the / menu go into card fields

Inserting a sound, a YouTube video or a study button through the / menu
in a card's field failed (found with the Snap build). Two causes:

A card field closes its editor when the focus leaves it, except to a
dialog. The / menu takes the focus too, so the editor was gone while
its menu was open, and the block chosen went to an editor that no
longer existed. The field now stays open while the editor's own popup
holds the focus (the editor package's keepEditorFocusNotifier, which
its editors heed for themselves, read through
RichContentEditor.popupHasFocus).

The editor package keeps the menu, and the layer closing it on a tap,
above everything until the chosen item's handler returns, so a block
asking in a dialog (formula, video, study button) had the menu over
that dialog. Our items now close the menu first; the handler is given
the menu for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-04 21:56:35 +02:00
commit 103881a28d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 60 additions and 5 deletions

View file

@ -105,9 +105,12 @@ CharacterShortcutEvent slashMenu(BuildContext context, EditorBlocks blocks) {
),
keywords: block.keywords,
// The menu's context is in the overlay; the editor's is the one
// with its surroundings.
handler: (editorState, menuService, menuContext) =>
block.insert(context, editorState, editorState.selection),
// with its surroundings. The menu goes first: it would stay above
// everything until the block is in, a dialog asking for it too.
handler: (editorState, menuService, menuContext) {
menuService.dismiss();
return block.insert(context, editorState, editorState.selection);
},
),
],
style: dark ? SelectionMenuStyle.dark : SelectionMenuStyle.light,

View file

@ -34,6 +34,11 @@ class const RichContentEditor({
final bool formatBar = false,
super.key,
}) extends StatefulWidget {
/// Whether an editor's popup (its `/` menu) has the focus for now: it
/// gives the focus back to its editor as it closes, so the editor losing
/// the focus meanwhile hasn't been left, and must stay.
static bool get popupHasFocus => keepEditorFocusNotifier.shouldKeepFocus;
@override
State<RichContentEditor> createState() => _RichContentEditorState();
}

View file

@ -91,9 +91,11 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
}
void _onFocusChange() {
// A dialog opening over the editor takes the focus too; collapsing
// then would drop the editor that dialog works for.
// A dialog opening over the editor takes the focus too, and so does
// the editor's `/` menu; collapsing then would drop the editor they
// work for.
if (!_focus.hasFocus &&
!RichContentEditor.popupHasFocus &&
mounted &&
(ModalRoute.of(context)?.isCurrent ?? true)) {
setState(() => _expanded = false);

View file

@ -273,6 +273,51 @@ void main() {
expect(blocks, contains(containsPair('type', mathBlockType)));
});
testWidgets('the / menu takes the focus without closing its field, so the '
'block chosen goes into the card', (tester) async {
await cards.create('japanese', card('猫', 'cat'));
await showStudyset(tester, size: const Size(412, 915));
await open(tester, '猫');
await tester.tap(find.text('cat', findRichText: true));
await settle(tester);
final editor = tester.widget<AppFlowyEditor>(
find.descendant(
of: find.byType(RichContentEditor),
matching: find.byType(AppFlowyEditor),
),
);
final slash = editor.characterShortcutEvents.singleWhere(
(event) => event.character == '/',
);
await slash.handler(editor.editorState);
await tester.pumpAndSettle();
expect(find.byType(RichContentEditor), findsOneWidget);
// The menu builds only the items near what it shows.
final menu = find.descendant(
of: find.byType(SelectionMenuWidget),
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(find.text('Formula'), 40, scrollable: menu);
await tester.ensureVisible(find.text('Formula'));
await tester.pumpAndSettle();
await tester.tap(find.text('Formula'));
await tester.pumpAndSettle();
expect(find.byType(SelectionMenuWidget), findsNothing);
await tester.enterText(find.byType(TextField), 'x^2');
await tester.tap(find.text('Save'));
await settle(tester);
await tester.tap(find.byType(BackButton));
await settle(tester);
final back = (await cards.cardsOf('japanese')).single.content.back;
final blocks =
(back.editorJson['document']! as Map<String, Object?>)['children']!
as List<Object?>;
expect(blocks, contains(containsPair('type', mathBlockType)));
});
testWidgets('the card types fit a phone, none of their names broken', (
tester,
) async {