improve find in page widget

This commit is contained in:
Fabian Freund
2026-02-01 10:22:05 +01:00
parent b19b9b2232
commit 6d2f9ff837
3 changed files with 200 additions and 98 deletions
@@ -52,6 +52,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
@@ -367,22 +368,40 @@ class BrowserScreen extends HookConsumerWidget {
// Get pixel ratio for converting logical pixels to physical pixels
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
// Watch find-in-page visibility for the selected tab
final selectedTabId = ref.watch(selectedTabProvider);
final findInPageVisible =
selectedTabId != null &&
ref.watch(
findInPageControllerProvider(
selectedTabId,
).select((state) => state.visible),
);
// Find in page widget height from the widget constant
final findInPageHeight = findInPageVisible
? FindInPageWidget.findInPageHeight
: 0.0;
// Set up GeckoView dynamic toolbar height
// This tells GeckoView the maximum toolbar space so it can adjust viewport
final bottomToolbarHeightPx = bottomToolbarVisible
? (bottomAppBarTotalHeight * pixelRatio).round()
: 0;
final baseToolbarHeight = bottomToolbarVisible
? bottomAppBarTotalHeight
: 0.0;
final totalToolbarHeight = findInPageVisible
? baseToolbarHeight + findInPageHeight
: baseToolbarHeight;
final toolbarHeightPx = (totalToolbarHeight * pixelRatio).round();
useEffect(() {
final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull;
if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) {
unawaited(
viewportService.setDynamicToolbarMaxHeight(bottomToolbarHeightPx),
);
unawaited(viewportService.setDynamicToolbarMaxHeight(toolbarHeightPx));
}
return null;
}, [bottomToolbarHeightPx]);
}, [toolbarHeightPx]);
// Listen to keyboard visibility changes from native
useOnStreamChange(
@@ -391,7 +410,15 @@ class BrowserScreen extends HookConsumerWidget {
if (event.isVisible && event.heightPx > 0) {
// When keyboard is visible, notify GeckoView to adjust viewport
// This uses the native API to handle keyboard without Flutter resize
unawaited(viewportService.setDynamicToolbarMaxHeight(event.heightPx));
// If find-in-page is also visible, add its height to the keyboard height
final findInPageHeightPx = findInPageVisible
? (FindInPageWidget.findInPageHeight * pixelRatio).round()
: 0;
unawaited(
viewportService.setDynamicToolbarMaxHeight(
event.heightPx + findInPageHeightPx,
),
);
}
},
);
@@ -552,6 +579,29 @@ class BrowserScreen extends HookConsumerWidget {
},
),
),
// Layer 6: Find in Page widget (above toolbar or keyboard, whichever is higher)
AnimatedPositioned(
duration: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
bottom: math.max(
bottomToolbarVisible
? bottomAppBarTotalHeight
: bottomSafeArea,
MediaQuery.viewInsetsOf(context).bottom,
),
child: Consumer(
builder: (context, ref, child) {
final tabId = ref.watch(selectedTabProvider);
if (tabId == null) {
return const SizedBox.shrink();
}
return FindInPageWidget(tabId: tabId);
},
),
),
],
),
),
@@ -830,37 +880,7 @@ class _BrowserView extends StatelessWidget {
bottom: false,
left: !isFullscreen,
child: Stack(
children: [
BrowserView(pointerMoveEventSink: pointerMoveEventSink),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Consumer(
builder: (context, ref, child) {
final value = ref.watch(
selectedTabStateProvider.select(
(state) => EdgeInsets.only(
bottom:
(state?.isLoading == true &&
state?.progress != null &&
state!.progress < 100)
? 4.0
: 0.0,
),
),
);
final tabId = ref.watch(selectedTabProvider);
if (tabId == null) {
return const SizedBox.shrink();
}
return FindInPageWidget(tabId: tabId, padding: value);
},
),
),
],
children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)],
),
);
}
@@ -22,11 +22,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/presentation/hooks/debouncer.dart';
class FindInPageWidget extends HookConsumerWidget {
final String tabId;
final EdgeInsetsGeometry padding;
/// The height of the find-in-page widget.
static const findInPageHeight = 56.0;
const FindInPageWidget({required this.tabId, this.padding = EdgeInsets.zero});
@override
@@ -39,76 +43,113 @@ class FindInPageWidget extends HookConsumerWidget {
final focusNode = useFocusNode();
final textController = useTextEditingController(
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText,
keys: [searchResult?.lastSearchText, findInPageState.lastSearchText],
);
// Update controller text when search result changes from external sources
// (e.g., when navigating between matches)
useEffect(() {
final currentText = textController.text;
final newText =
searchResult?.lastSearchText ?? findInPageState.lastSearchText ?? '';
if (currentText != newText) {
textController.text = newText;
}
return null;
}, [searchResult?.lastSearchText, findInPageState.lastSearchText]);
// Create debouncer with automatic disposal
final debouncer = useDebouncer(const Duration(milliseconds: 300));
// Search function that handles debouncing
Future<void> onSearchTextChanged(String value) async {
if (value.isEmpty) {
debouncer.dispose();
await ref
.read(findInPageControllerProvider(tabId).notifier)
.clearMatches();
} else {
debouncer.eventOccured(() async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findAll(text: value);
});
}
}
return Visibility(
visible: findInPageState.visible || searchResult?.hasMatches == true,
child: Padding(
padding: padding,
child: Material(
child: Row(
children: [
const SizedBox(width: 8),
Expanded(
child: TextField(
focusNode: focusNode,
controller: textController,
autofocus: true,
autocorrect: false,
decoration: const InputDecoration.collapsed(
hintText: 'Find in page',
child: SizedBox(
height: findInPageHeight,
child: Row(
children: [
const SizedBox(width: 8),
Expanded(
child: TextField(
focusNode: focusNode,
controller: textController,
autofocus: true,
autocorrect: false,
decoration: const InputDecoration.collapsed(
hintText: 'Find in page',
),
keyboardType: TextInputType.text,
onChanged: onSearchTextChanged,
onSubmitted: (value) async {
// Cancel pending debounce and execute immediately
debouncer.dispose();
if (value.isEmpty) {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.clearMatches();
} else {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findAll(text: value);
}
},
),
keyboardType: TextInputType.text,
onSubmitted: (value) async {
if (value == '') {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.clearMatches();
} else {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findAll(text: value);
}
),
Text(
(searchResult != null && searchResult.hasMatches)
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
: 'Not found',
),
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findNext(
forward: false,
fallbackText: textController.text,
);
},
),
),
Text(
(searchResult != null && searchResult.hasMatches)
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
: 'Not found',
),
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findNext(
forward: false,
fallbackText: textController.text,
);
},
),
IconButton(
icon: const Icon(Icons.arrow_downward),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findNext(fallbackText: textController.text);
},
),
IconButton(
icon: const Icon(Icons.clear),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.hide();
IconButton(
icon: const Icon(Icons.arrow_downward),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findNext(fallbackText: textController.text);
},
),
IconButton(
icon: const Icon(Icons.clear),
onPressed: () async {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.hide();
textController.clear();
focusNode.requestFocus();
},
),
],
textController.clear();
focusNode.requestFocus();
},
),
],
),
),
),
),
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024-2025 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:weblibre/utils/debouncer.dart';
/// A hook that creates a [Debouncer] with automatic disposal.
///
/// The debouncer will be created once and automatically disposed when the
/// widget is unmounted.
///
/// Example:
/// ```dart
/// final debouncer = useDebouncer(const Duration(milliseconds: 300));
///
/// // Use the debouncer
/// debouncer.eventOccured(() {
/// // Your debounced action
/// });
/// ```
Debouncer useDebouncer(Duration duration) {
final debouncer = useMemoized(() => Debouncer(duration));
useEffect(() => debouncer.dispose, [debouncer]);
return debouncer;
}