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_list_view.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_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/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/find_in_page/presentation/widgets/find_in_page.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.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
|
// Get pixel ratio for converting logical pixels to physical pixels
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
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
|
// Set up GeckoView dynamic toolbar height
|
||||||
// This tells GeckoView the maximum toolbar space so it can adjust viewport
|
// This tells GeckoView the maximum toolbar space so it can adjust viewport
|
||||||
final bottomToolbarHeightPx = bottomToolbarVisible
|
final baseToolbarHeight = bottomToolbarVisible
|
||||||
? (bottomAppBarTotalHeight * pixelRatio).round()
|
? bottomAppBarTotalHeight
|
||||||
: 0;
|
: 0.0;
|
||||||
|
final totalToolbarHeight = findInPageVisible
|
||||||
|
? baseToolbarHeight + findInPageHeight
|
||||||
|
: baseToolbarHeight;
|
||||||
|
final toolbarHeightPx = (totalToolbarHeight * pixelRatio).round();
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull;
|
final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull;
|
||||||
|
|
||||||
if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) {
|
if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) {
|
||||||
unawaited(
|
unawaited(viewportService.setDynamicToolbarMaxHeight(toolbarHeightPx));
|
||||||
viewportService.setDynamicToolbarMaxHeight(bottomToolbarHeightPx),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [bottomToolbarHeightPx]);
|
}, [toolbarHeightPx]);
|
||||||
|
|
||||||
// Listen to keyboard visibility changes from native
|
// Listen to keyboard visibility changes from native
|
||||||
useOnStreamChange(
|
useOnStreamChange(
|
||||||
@@ -391,7 +410,15 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
if (event.isVisible && event.heightPx > 0) {
|
if (event.isVisible && event.heightPx > 0) {
|
||||||
// When keyboard is visible, notify GeckoView to adjust viewport
|
// When keyboard is visible, notify GeckoView to adjust viewport
|
||||||
// This uses the native API to handle keyboard without Flutter resize
|
// 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,
|
bottom: false,
|
||||||
left: !isFullscreen,
|
left: !isFullscreen,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)],
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-59
@@ -22,11 +22,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||||
|
import 'package:weblibre/presentation/hooks/debouncer.dart';
|
||||||
|
|
||||||
class FindInPageWidget extends HookConsumerWidget {
|
class FindInPageWidget extends HookConsumerWidget {
|
||||||
final String tabId;
|
final String tabId;
|
||||||
final EdgeInsetsGeometry padding;
|
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});
|
const FindInPageWidget({required this.tabId, this.padding = EdgeInsets.zero});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,76 +43,113 @@ class FindInPageWidget extends HookConsumerWidget {
|
|||||||
final focusNode = useFocusNode();
|
final focusNode = useFocusNode();
|
||||||
final textController = useTextEditingController(
|
final textController = useTextEditingController(
|
||||||
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText,
|
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(
|
return Visibility(
|
||||||
visible: findInPageState.visible || searchResult?.hasMatches == true,
|
visible: findInPageState.visible || searchResult?.hasMatches == true,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
child: Material(
|
child: Material(
|
||||||
child: Row(
|
child: SizedBox(
|
||||||
children: [
|
height: findInPageHeight,
|
||||||
const SizedBox(width: 8),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: TextField(
|
const SizedBox(width: 8),
|
||||||
focusNode: focusNode,
|
Expanded(
|
||||||
controller: textController,
|
child: TextField(
|
||||||
autofocus: true,
|
focusNode: focusNode,
|
||||||
autocorrect: false,
|
controller: textController,
|
||||||
decoration: const InputDecoration.collapsed(
|
autofocus: true,
|
||||||
hintText: 'Find in page',
|
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 {
|
Text(
|
||||||
if (value == '') {
|
(searchResult != null && searchResult.hasMatches)
|
||||||
await ref
|
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
|
||||||
.read(findInPageControllerProvider(tabId).notifier)
|
: 'Not found',
|
||||||
.clearMatches();
|
),
|
||||||
} else {
|
IconButton(
|
||||||
await ref
|
icon: const Icon(Icons.arrow_upward),
|
||||||
.read(findInPageControllerProvider(tabId).notifier)
|
onPressed: () async {
|
||||||
.findAll(text: value);
|
await ref
|
||||||
}
|
.read(findInPageControllerProvider(tabId).notifier)
|
||||||
|
.findNext(
|
||||||
|
forward: false,
|
||||||
|
fallbackText: textController.text,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
Text(
|
icon: const Icon(Icons.arrow_downward),
|
||||||
(searchResult != null && searchResult.hasMatches)
|
onPressed: () async {
|
||||||
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
|
await ref
|
||||||
: 'Not found',
|
.read(findInPageControllerProvider(tabId).notifier)
|
||||||
),
|
.findNext(fallbackText: textController.text);
|
||||||
IconButton(
|
},
|
||||||
icon: const Icon(Icons.arrow_upward),
|
),
|
||||||
onPressed: () async {
|
IconButton(
|
||||||
await ref
|
icon: const Icon(Icons.clear),
|
||||||
.read(findInPageControllerProvider(tabId).notifier)
|
onPressed: () async {
|
||||||
.findNext(
|
await ref
|
||||||
forward: false,
|
.read(findInPageControllerProvider(tabId).notifier)
|
||||||
fallbackText: textController.text,
|
.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();
|
textController.clear();
|
||||||
focusNode.requestFocus();
|
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