From bde6c4e0ce5bcf16696f2fa080d8a2ec527efce5 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 23 Aug 2025 05:30:42 +0800 Subject: [PATCH] search json text (#554)(#507)(#110) --- README.md | 3 +- README_CN.md | 5 +- lib/ui/component/json/json_text.dart | 230 +++++++++++++++----- lib/ui/component/json/theme.dart | 62 ++++-- lib/ui/component/search/highlight_text.dart | 9 +- lib/ui/content/body.dart | 6 +- 6 files changed, 228 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 5f79be6..b7d251c 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ If ProxyPin is helpful to you, you are welcome to support us in the following wa **Your support will be used for project maintenance, feature development, and user experience optimization. Thank you very much!** +## Downloads -Downloads: https://github.com/wanghongenpin/proxypin/releases +Github Releases: https://github.com/wanghongenpin/proxypin/releases iOS App Store:https://apps.apple.com/app/proxypin/id6450932949 diff --git a/README_CN.md b/README_CN.md index 5e91ec4..0215b63 100644 --- a/README_CN.md +++ b/README_CN.md @@ -30,10 +30,11 @@ **您的支持将用于项目的维护、功能开发和用户体验优化,非常感谢!** +## 下载地址 -国内下载地址: https://gitee.com/wanghongenpin/proxypin/releases +国内下载: https://gitee.com/wanghongenpin/proxypin/releases -iOS AppStore下载地址: https://apps.apple.com/app/proxypin/id6450932949 +iOS App Store: https://apps.apple.com/app/proxypin/id6450932949 Android Google Play:https://play.google.com/store/apps/details?id=com.network.proxy diff --git a/lib/ui/component/json/json_text.dart b/lib/ui/component/json/json_text.dart index 3e4caa4..a0a61e6 100644 --- a/lib/ui/component/json/json_text.dart +++ b/lib/ui/component/json/json_text.dart @@ -17,71 +17,153 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/ui/component/json/theme.dart'; +import 'package:proxypin/ui/component/search/search_controller.dart'; -class JsonText extends StatelessWidget { +import '../../../utils/platform.dart'; + +class JsonText extends StatefulWidget { final ColorTheme colorTheme; final dynamic json; final String indent; final ScrollController? scrollController; + final SearchTextController? searchController; - const JsonText({super.key, required this.json, this.indent = ' ', required this.colorTheme, this.scrollController}); + const JsonText({ + super.key, + required this.json, + this.indent = ' ', + required this.colorTheme, + this.scrollController, + this.searchController, + }); + + @override + State createState() => _JsonTextState(); +} + +class _JsonTextState extends State { + ScrollController? trackingScrollController; + SearchTextController? searchController; + + @override + initState() { + super.initState(); + searchController = widget.searchController; + } + + @override + void dispose() { + trackingScrollController?.dispose(); + trackingScrollController = null; + logger.d('JsonText dispose'); + super.dispose(); + } @override Widget build(BuildContext context) { - var jsnParser = JsnParser(json, colorTheme, indent); - var textList = jsnParser.getJsonTree(); + if (searchController == null) { + return jsonTextWidget(context); + } + return AnimatedBuilder( + animation: searchController!, + builder: (context, child) { + return jsonTextWidget(context); + }, + ); + } - Widget widget; + double getAvailableHeight(BuildContext context) { + // 获取当前组件可用高度(屏幕高度减去系统padding和AppBar高度等) + final mediaQuery = MediaQuery.of(context); + final appBar = Scaffold.of(context).appBarMaxHeight ?? 0; + return mediaQuery.size.height - mediaQuery.padding.top - appBar; + } + + Widget jsonTextWidget(BuildContext context) { + var jsonParser = JsonParser(widget.json, widget.colorTheme, widget.indent, searchController); + var textList = jsonParser.getJsonTree(); + WidgetsBinding.instance.addPostFrameCallback((_) { + searchController?.updateMatchCount(jsonParser.searchMatchTotal); + // 自动滚动到当前高亮项 + scrollToMatch(jsonParser); + }); if (textList.length < 1500) { - widget = Column(crossAxisAlignment: CrossAxisAlignment.start, children: textList); + return SelectableText.rich(TextSpan(children: textList), showCursor: true); } else { - widget = SizedBox( + return SizedBox( width: double.infinity, height: MediaQuery.of(context).size.height - 160, - child: ListView.builder( - physics: const BouncingScrollPhysics(), - controller: trackingScroll(), - cacheExtent: 1000, - itemBuilder: (context, index) => textList[index], - itemCount: textList.length)); + child: SingleChildScrollView( + physics: Platforms.isDesktop() ? null : const BouncingScrollPhysics(), + controller: Platforms.isDesktop() ? null : trackingScroll(), + child: SelectableText.rich(TextSpan(children: textList), showCursor: true))); + } + } + + void scrollToMatch(JsonParser jsonParser) { + if (searchController != null && jsonParser.matchKeys.isNotEmpty) { + final currentIndex = searchController!.currentMatchIndex.value; + if (currentIndex >= 0 && currentIndex < jsonParser.matchKeys.length) { + final key = jsonParser.matchKeys[currentIndex]; + final context = key.currentContext; + if (context != null) { + logger.d('scrollToMatch: currentIndex=$currentIndex, key=$key'); + + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + alignment: 0.5, // 高亮项在视图中的位置 + ); + } + } } - return SelectionArea(child: widget); } ///滚动条 ScrollController trackingScroll() { + if (trackingScrollController != null) { + return trackingScrollController!; + } + var trackingScroll = TrackingScrollController(); + ScrollController? scrollController = widget.scrollController; + double offset = 0; trackingScroll.addListener(() { if (trackingScroll.offset < -10 || (trackingScroll.offset < 30 && trackingScroll.offset < offset)) { - if (scrollController != null && scrollController!.offset >= 0) { - scrollController?.jumpTo(scrollController!.offset - max((offset - trackingScroll.offset), 15)); + if (scrollController != null && scrollController.offset >= 0) { + scrollController.jumpTo(scrollController.offset - max((offset - trackingScroll.offset), 15)); } } offset = trackingScroll.offset; }); - if (Platform.isIOS) { - scrollController?.addListener(() { - if (scrollController!.offset >= scrollController!.position.maxScrollExtent) { - scrollController?.jumpTo(scrollController!.position.maxScrollExtent); + if (Platform.isIOS && scrollController != null) { + scrollController.addListener(() { + if (scrollController.offset >= scrollController.position.maxScrollExtent) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); trackingScroll - .jumpTo(trackingScroll.offset + (scrollController!.offset - scrollController!.position.maxScrollExtent)); + .jumpTo(trackingScroll.offset + (scrollController.offset - scrollController.position.maxScrollExtent)); } }); } + trackingScrollController = trackingScroll; return trackingScroll; } } -class JsnParser { +class JsonParser { final dynamic json; final ColorTheme colorTheme; final String indent; + final SearchTextController? searchController; + int searchMatchTotal = 0; + final List matchKeys = []; - JsnParser(this.json, this.colorTheme, this.indent); + JsonParser(this.json, this.colorTheme, this.indent, this.searchController); int getLength() { if (json is Map) { @@ -93,24 +175,26 @@ class JsnParser { } } - List getJsonTree() { - List textList = []; + List getJsonTree() { + matchKeys.clear(); // 每次渲染前清空 + List textList = []; if (json is Map) { - textList.add(const Text('{')); + textList.add(const TextSpan(text: '{ \n')); textList.addAll(getMapText(json, prefix: indent)); } else if (json is List) { - textList.add(const Text('[')); + textList.add(const TextSpan(text: '[ \n')); textList.addAll(getArrayText(json)); } else { - textList.add(Text(json == null ? '' : json.toString())); + textList.add(TextSpan(text: json == null ? '' : json.toString())); + textList.add(TextSpan(text: '\n')); } return textList; } /// 获取Map json - List getMapText(Map map, {String openPrefix = '', String prefix = '', String suffix = ''}) { - var result = []; - // result.add(Text('$openPrefix{')); + List getMapText(Map map, + {String openPrefix = '', String prefix = '', String suffix = ''}) { + var result = []; var entries = map.entries; for (int i = 0; i < entries.length; i++) { @@ -118,11 +202,12 @@ class JsnParser { String postfix = '${i == entries.length - 1 ? '' : ','} '; var textSpan = TextSpan(text: prefix, children: [ - TextSpan(text: '"${entry.key}"', style: TextStyle(color: colorTheme.propertyKey)), + ..._highlightMatches('"${entry.key}"', textColor: colorTheme.propertyKey), const TextSpan(text: ': '), getBasicValue(entry.value, postfix), ]); - result.add(Text.rich(textSpan)); + result.add(textSpan); + result.add(TextSpan(text: '\n')); if (entry.value is Map) { result.addAll(getMapText(entry.value, openPrefix: prefix, prefix: '$prefix$indent', suffix: postfix)); @@ -131,20 +216,21 @@ class JsnParser { } } - result.add(Text('$openPrefix}$suffix')); + result.add(TextSpan(text: '$openPrefix}$suffix \n')); return result; } /// 获取数组json - List getArrayText(List list, {String openPrefix = '', String prefix = '', String suffix = ''}) { - var result = []; - // result.add(Text('$openPrefix[')); + List getArrayText(List list, {String openPrefix = '', String prefix = '', String suffix = ''}) { + var result = []; + result.add(TextSpan(text: '$openPrefix[ \n')); for (int i = 0; i < list.length; i++) { var value = list[i]; String postfix = i == list.length - 1 ? '' : ','; - result.add(Text.rich(getBasicValue(value, postfix, prefix: prefix))); + result.add(getBasicValue(value, postfix, prefix: prefix)); + result.add(TextSpan(text: '\n')); if (value is Map) { result.addAll(getMapText(value, openPrefix: '$openPrefix ', prefix: '$prefix$indent', suffix: postfix)); @@ -153,42 +239,82 @@ class JsnParser { } } - result.add(Text('$openPrefix]$suffix')); + result.add(TextSpan(text: '$openPrefix]$suffix \n')); return result; } /// 获取基本类型值 复杂类型会忽略 - InlineSpan getBasicValue(dynamic value, String suffix, {String? prefix}) { + TextSpan getBasicValue(dynamic value, String suffix, {String? prefix}) { if (value == null) { return TextSpan( text: prefix, - children: [TextSpan(text: 'null', style: TextStyle(color: colorTheme.keyword)), TextSpan(text: suffix)]); + children: [..._highlightMatches('null', textColor: colorTheme.keyword), TextSpan(text: suffix)]); } if (value is String) { return TextSpan( text: prefix, - children: [TextSpan(text: '"$value"', style: TextStyle(color: colorTheme.string)), TextSpan(text: suffix)]); + children: [..._highlightMatches('"$value"', textColor: colorTheme.string), TextSpan(text: suffix)]); } if (value is num) { - return TextSpan(text: prefix, children: [ - TextSpan(text: value.toString(), style: TextStyle(color: colorTheme.number)), - TextSpan(text: suffix) - ]); + return TextSpan( + text: prefix, + children: [..._highlightMatches(value.toString(), textColor: colorTheme.number), TextSpan(text: suffix)]); } if (value is bool) { - return TextSpan(text: prefix, children: [ - TextSpan(text: value.toString(), style: TextStyle(color: colorTheme.keyword)), - TextSpan(text: suffix) - ]); + return TextSpan( + text: prefix, + children: [..._highlightMatches(value.toString(), textColor: colorTheme.keyword), TextSpan(text: suffix)]); } if (value is List) { - return TextSpan(text: "${prefix ?? ''}["); + return TextSpan(children: _highlightMatches("${prefix ?? ''}[")); } - return TextSpan(text: "${prefix ?? ''}{"); + return TextSpan(children: _highlightMatches("${prefix ?? ''}{")); + } + + List _highlightMatches(String text, {Color? textColor}) { + if (searchController == null || searchController?.shouldSearch() == false) { + return [TextSpan(text: text, style: TextStyle(color: textColor))]; + } + + final pattern = searchController!.value.pattern; + final regex = searchController!.value.isRegExp + ? RegExp(pattern, caseSensitive: searchController!.value.isCaseSensitive) + : RegExp(RegExp.escape(pattern), caseSensitive: searchController!.value.isCaseSensitive); + + final spans = []; + int start = 0; + var allMatches = regex.allMatches(text).toList(); + final currentIndex = searchController!.currentMatchIndex.value; + for (int i = 0; i < allMatches.length; i++) { + final match = allMatches[i]; + if (match.start > start) { + spans.add(TextSpan(text: text.substring(start, match.start), style: TextStyle(color: textColor))); + } + // 为每个高亮项分配一个 GlobalKey + final key = GlobalKey(); + matchKeys.add(key); + spans.add(WidgetSpan( + child: Container( + key: key, + color: searchMatchTotal == currentIndex ? colorTheme.searchMatchCurrentColor : colorTheme.searchMatchColor, + child: Text( + text.substring(match.start, match.end), + style: TextStyle(color: textColor), + ), + ), + )); + start = match.end; + searchMatchTotal += 1; // 统计总匹配数 + } + + if (start < text.length) { + spans.add(TextSpan(text: text.substring(start), style: TextStyle(color: textColor))); + } + return spans; } } diff --git a/lib/ui/component/json/theme.dart b/lib/ui/component/json/theme.dart index f7a1c86..8165af3 100644 --- a/lib/ui/component/json/theme.dart +++ b/lib/ui/component/json/theme.dart @@ -1,20 +1,27 @@ import 'package:flutter/material.dart'; -enum ColorTheme { - light( - background: Color(0xffffffff), - propertyKey: Color(0xff871094), - colon: Colors.black, - string: Color(0xff067d17), - number: Color(0xff1750eb), - keyword: Color(0xff0033b3)), - dark( - background: Color(0xff2b2b2b), - propertyKey: Color(0xff9876aa), - colon: Color(0xffcc7832), - string: Color(0xff6a8759), - number: Color(0xff6897bb), - keyword: Color(0xffcc7832)); +class ColorTheme { + static ColorTheme light(ColorScheme colorScheme) => ColorTheme( + background: const Color(0xffffffff), + propertyKey: const Color(0xff871094), + colon: Colors.black, + string: const Color(0xff067d17), + number: const Color(0xff1750eb), + keyword: const Color(0xff0033b3), + searchMatchColor: colorScheme.inversePrimary, + searchMatchCurrentColor: colorScheme.primary, + ); + + static ColorTheme dark(ColorScheme colorScheme) => ColorTheme( + background: const Color(0xff2b2b2b), + propertyKey: const Color(0xff9876aa), + colon: const Color(0xffcc7832), + string: const Color(0xff6a8759), + number: const Color(0xff6897bb), + keyword: const Color(0xffcc7832), + searchMatchColor: colorScheme.inversePrimary, + searchMatchCurrentColor: colorScheme.primary, + ); final Color background; final Color propertyKey; @@ -22,16 +29,23 @@ enum ColorTheme { final Color string; final Color number; final Color keyword; + final Color? searchMatchColor; + final Color? searchMatchCurrentColor; - const ColorTheme( - {required this.background, - required this.propertyKey, - required this.colon, - required this.string, - required this.number, - required this.keyword}); + const ColorTheme({ + required this.background, + required this.propertyKey, + required this.colon, + required this.string, + required this.number, + required this.keyword, + required this.searchMatchColor, + required this.searchMatchCurrentColor, + }); - static ColorTheme of(Brightness brightness) { - return brightness == Brightness.dark ? ColorTheme.dark : ColorTheme.light; + static ColorTheme of(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + return brightness == Brightness.dark ? ColorTheme.dark(colorScheme) : ColorTheme.light(colorScheme); } } diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index 9c39438..d0436cd 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -47,6 +47,7 @@ class HighlightTextWidget extends StatelessWidget { int start = 0; var allMatches = regex.allMatches(text).toList(); final currentIndex = searchController.currentMatchIndex.value; + ColorScheme colorScheme = ColorScheme.of(context); List matchOffsets = []; for (int i = 0; i < allMatches.length; i++) { final match = allMatches[i]; @@ -57,7 +58,7 @@ class HighlightTextWidget extends StatelessWidget { spans.add(TextSpan( text: text.substring(match.start, match.end), style: TextStyle( - backgroundColor: i == currentIndex ? Colors.orange : Colors.yellow, + backgroundColor: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, ), )); start = match.end; @@ -87,10 +88,6 @@ class HighlightTextWidget extends StatelessWidget { ); tp.layout(maxWidth: scrollController!.position.viewportDimension); final offset = tp.height; - scrollController!.animateTo( - offset, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + scrollController!.animateTo(offset, duration: const Duration(milliseconds: 300), curve: Curves.ease); } } diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 0d1ec08..9151f87 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -98,6 +98,7 @@ class HttpBodyState extends State { void dispose() { HardwareKeyboard.instance.removeHandler(onKeyEvent); searchController.dispose(); + widget.scrollController?.dispose(); super.dispose(); } @@ -468,12 +469,13 @@ class _BodyState extends State<_Body> { return JsonText( json: jsonObject, indent: Platforms.isDesktop() ? ' ' : ' ', - colorTheme: ColorTheme.of(Theme.of(context).brightness), + colorTheme: ColorTheme.of(context), + searchController: widget.searchController, scrollController: widget.scrollController); } if (type == ViewType.json) { - return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(Theme.of(context).brightness)); + return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(context)); } return HighlightTextWidget(