improve find in page widget
This commit is contained in:
@@ -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)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+100
-59
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user