fix(app): a click in a short editor no longer freezes the app
All checks were successful
CI / check (push) Successful in 9m39s
CI / deploy (push) Successful in 14s

The editor scrolls a new selection into view by keeping a box around it,
220 px by default, inside its viewport. A card's field is shorter than
that (180 px in the PDF reader, 200 in the composer), so once its content
could scroll, as with a screenshot taller than the field, the box stuck
out at both ends: the editor scrolled up and down for ever, drew no
frame again, and took ever more memory. RichContentEditor now keeps the
box at most half its own height (autoScrollEdgeOffset).

Found clicking a screenshot in a card in the PDF reader, on Linux.

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-05 18:33:57 +02:00
commit 475ff99ead
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 84 additions and 10 deletions

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -126,16 +127,28 @@ class _RichContentEditorState() extends State<RichContentEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final blocks = blocksOf(context); final blocks = blocksOf(context);
final editor = AppFlowyEditor( final editor = LayoutBuilder(
editorState: _editorState, builder: (context, constraints) => AppFlowyEditor(
editorStyle: editorStyleOf(context), editorState: _editorState,
blockComponentBuilders: buildersWithUnknown(blocks), editorStyle: editorStyleOf(context),
characterShortcutEvents: characterShortcutsOf( blockComponentBuilders: buildersWithUnknown(blocks),
blocks, characterShortcutEvents: characterShortcutsOf(
slashMenu(context, blocks), blocks,
slashMenu(context, blocks),
),
autoFocus: widget.autofocus,
focusNode: _focus,
// The box the editor keeps around the selection while scrolling it
// into view has to fit inside the editor: a taller one, as the
// default is for a card's field, sticks out at both ends, and the
// editor then scrolls up and down for ever, the app frozen.
autoScrollEdgeOffset: constraints.hasBoundedHeight
? math.min(
appFlowyEditorAutoScrollEdgeOffset,
constraints.maxHeight / 2,
)
: appFlowyEditorAutoScrollEdgeOffset,
), ),
autoFocus: widget.autofocus,
focusNode: _focus,
); );
if (!widget.formatBar) { if (!widget.formatBar) {
return editor; return editor;

View file

@ -0,0 +1,48 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets('a click in an editor shorter than its content selects there, '
'and bringing the selection into view comes to an end', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: ListView(
children: [
SizedBox(
// As short as a card's field: shorter than the box the
// editor keeps around the selection while scrolling to it.
height: 150,
child: RichContentEditor(
initialContent: RichContent.plain(
[for (var line = 1; line <= 40; line++) 'Line $line']
.join('\n'),
),
onChanged: (_) {},
),
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.text('Line 3', findRichText: true));
// The frame comes: with a box taller than the editor, the editor
// scrolled up and down for ever and never drew one.
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
final editorState = tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
expect(editorState.selection?.start.path, [2]);
});
}

View file

@ -230,7 +230,7 @@ void main() {
}); });
testWidgets('a screenshot of a region of the page goes into the card as ' testWidgets('a screenshot of a region of the page goes into the card as '
'an image that knows its page', (tester) async { 'an image that knows its page, which a click selects', (tester) async {
final id = await stored(); final id = await stored();
await read(tester, id); await read(tester, id);
await tester.tap(find.text('Create flashcard')); await tester.tap(find.text('Create flashcard'));
@ -269,6 +269,19 @@ void main() {
expect(image.attributes[imagePdfKey], id); expect(image.attributes[imagePdfKey], id);
expect(image.attributes[imagePageKey], 1); expect(image.attributes[imagePageKey], 1);
expect(find.text('Screenshot saved'), findsOneWidget); expect(find.text('Screenshot saved'), findsOneWidget);
// Taller than the field, the image lets it scroll; a click on it once
// made the editor scroll up and down for ever.
await tester.tap(
find.descendant(
of: find.byType(RichContentEditor).first,
matching: find.byType(Image),
),
);
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(editorState.selection?.start.path, image.path);
}); });
testWidgets('on a phone, the pages fill the screen, the panels open from ' testWidgets('on a phone, the pages fill the screen, the panels open from '