mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-04-15 21:03:48 +08:00
feat: add CSS and JS formatting support and improve body text handling
This commit is contained in:
@@ -39,7 +39,7 @@ class HighlightTextWidget extends StatelessWidget {
|
||||
return SelectableText.rich(
|
||||
TextSpan(children: spans),
|
||||
showCursor: true,
|
||||
selectionColor: highlightSelectionColor(context),
|
||||
// selectionColor: highlightSelectionColor(context),
|
||||
contextMenuBuilder: contextMenuBuilder,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'search_controller.dart';
|
||||
|
||||
class HighlightTextDocument {
|
||||
final String text;
|
||||
final TextStyle rootStyle;
|
||||
final TextStyle? rootStyle;
|
||||
final List<HighlightStyledSegment> segments;
|
||||
final List<HighlightSearchMatch> matches;
|
||||
final List<HighlightDocumentLine> lines;
|
||||
@@ -113,9 +113,12 @@ class HighlightTextDocument {
|
||||
final overlapEnd = match.end < segmentEnd ? match.end : segmentEnd;
|
||||
final matchText = segment.text.substring(localStart, overlapEnd - segmentStart);
|
||||
final isCurrentMatch = match.index == currentMatchIndex;
|
||||
final highlightedStyle = (segment.style ?? const TextStyle()).copyWith(
|
||||
|
||||
// 复用样式计算,减少对象创建
|
||||
final baseStyle = segment.style ?? const TextStyle();
|
||||
final highlightedStyle = baseStyle.copyWith(
|
||||
backgroundColor: isCurrentMatch ? colorScheme.primary : colorScheme.inversePrimary,
|
||||
color: isCurrentMatch ? colorScheme.onPrimary : segment.style?.color,
|
||||
color: isCurrentMatch ? colorScheme.onPrimary : baseStyle.color,
|
||||
);
|
||||
|
||||
_appendTextSpan(spans, matchText, highlightedStyle);
|
||||
@@ -131,7 +134,7 @@ class HighlightTextDocument {
|
||||
|
||||
List<InlineSpan> _plainLineSpans(HighlightDocumentLine line) {
|
||||
if (line.segments.isEmpty) {
|
||||
return [const TextSpan(text: '\u200B', style: TextStyle(color: Colors.transparent))];
|
||||
return [const TextSpan(text: '', style: TextStyle(color: Colors.transparent))];
|
||||
}
|
||||
|
||||
return [for (final segment in line.segments) TextSpan(text: segment.text, style: segment.style)];
|
||||
@@ -185,16 +188,6 @@ List<HighlightStyledSegment> buildHighlightBaseSegments(
|
||||
return [HighlightStyledSegment(text: text, style: _stripBackground(style))];
|
||||
}
|
||||
|
||||
Color highlightSelectionColor(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final configured = Theme.of(context).textSelectionTheme.selectionColor;
|
||||
// Enforce minimum contrast for syntax-colored text selection.
|
||||
if (configured != null && configured.alpha >= 140) {
|
||||
return configured;
|
||||
}
|
||||
return scheme.primary.withValues(alpha: 0.55);
|
||||
}
|
||||
|
||||
List<HighlightSearchMatch> buildSearchMatches(String text, SearchTextController searchController) {
|
||||
if (!searchController.shouldSearch()) {
|
||||
return const [];
|
||||
@@ -351,4 +344,3 @@ TextStyle? _stripBackground(TextStyle? style) {
|
||||
}
|
||||
return style.copyWith(backgroundColor: null, background: null);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import 'package:proxypin/ui/component/utils.dart';
|
||||
import 'package:proxypin/ui/desktop/setting/request_rewrite.dart';
|
||||
import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
|
||||
import 'package:proxypin/utils/crypto_body_decoder.dart';
|
||||
import 'package:proxypin/utils/css_formatter.dart';
|
||||
import 'package:proxypin/utils/html_formatter.dart';
|
||||
import 'package:proxypin/utils/js_formatter.dart';
|
||||
import 'package:proxypin/utils/lang.dart';
|
||||
import 'package:proxypin/utils/num.dart';
|
||||
import 'package:proxypin/utils/platform.dart';
|
||||
@@ -117,7 +119,9 @@ class HttpBodyState extends State<HttpBodyWidget> {
|
||||
final message = widget.httpMessage;
|
||||
if (message == null) return;
|
||||
decoded = await CryptoBodyDecoder.maybeDecode(message);
|
||||
if (mounted) setState(() {});
|
||||
if (mounted && decoded != null && decoded!.hasText) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -489,6 +493,8 @@ class _Body extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BodyState extends State<_Body> {
|
||||
static const int _virtualizedThreshold = 100000;
|
||||
|
||||
late ViewType viewType;
|
||||
HttpMessage? message;
|
||||
|
||||
@@ -532,6 +538,14 @@ class _BodyState extends State<_Body> {
|
||||
return XML.pretty(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.css) {
|
||||
return CSS.pretty(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.js) {
|
||||
return JS.pretty(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.jsonText || type == ViewType.json) {
|
||||
var jsonObject = json.decode(body);
|
||||
return const JsonEncoder.withIndent(" ").convert(jsonObject);
|
||||
@@ -673,28 +687,21 @@ class _BodyState extends State<_Body> {
|
||||
HttpMessage? message,
|
||||
}) {
|
||||
final language = _languageForViewType(type, message);
|
||||
|
||||
// bool showVirtualized = text.length > 12000;
|
||||
final formattedText = language != null ? _formatTextBody(type, text) : text;
|
||||
// final showVirtualized = formattedText.length > _virtualizedThreshold;
|
||||
// if (showVirtualized) {
|
||||
// logger.d(
|
||||
// "Using virtualized viewer for type $type with length ${text.length} and line count ${'\n'.allMatches(text).length + 1}");
|
||||
// return VirtualizedHighlightText(
|
||||
// text: text,
|
||||
// text: formattedText,
|
||||
// language: language,
|
||||
// searchController: widget.searchController,
|
||||
// contextMenuBuilder: contextMenu,
|
||||
// searchController: widget.searchController,
|
||||
// scrollController: widget.scrollController,
|
||||
// );
|
||||
// }
|
||||
|
||||
if (type == ViewType.text) {
|
||||
return HighlightTextWidget(
|
||||
text: text, searchController: widget.searchController, contextMenuBuilder: contextMenu);
|
||||
}
|
||||
|
||||
return HighlightTextWidget(
|
||||
language: language,
|
||||
text: _formatTextBody(type, text),
|
||||
text: formattedText,
|
||||
searchController: widget.searchController,
|
||||
contextMenuBuilder: contextMenu);
|
||||
}
|
||||
@@ -719,6 +726,13 @@ class Tabs {
|
||||
tabs.list.add(ViewType.jsonText);
|
||||
}
|
||||
|
||||
if (contentType == ContentType.html ||
|
||||
contentType == ContentType.xml ||
|
||||
contentType == ContentType.js ||
|
||||
contentType == ContentType.css) {
|
||||
tabs.list.add(ViewType.text);
|
||||
}
|
||||
|
||||
tabs.list.add(ViewType.of(contentType) ?? ViewType.text);
|
||||
|
||||
//为json时,增加json格式化
|
||||
@@ -727,10 +741,7 @@ class Tabs {
|
||||
tabs.list.add(ViewType.json);
|
||||
}
|
||||
|
||||
if (contentType == ContentType.formUrl ||
|
||||
contentType == ContentType.json ||
|
||||
contentType == ContentType.html ||
|
||||
contentType == ContentType.xml) {
|
||||
if (contentType == ContentType.formUrl || contentType == ContentType.json) {
|
||||
tabs.list.add(ViewType.text);
|
||||
}
|
||||
|
||||
|
||||
157
lib/utils/css_formatter.dart
Normal file
157
lib/utils/css_formatter.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
class CSS {
|
||||
/// 格式化 CSS 字符串
|
||||
static String pretty(String input) {
|
||||
if (input.trim().isEmpty || !input.contains('{')) return input;
|
||||
try {
|
||||
final result = _CssFormatter(input).format();
|
||||
return result.isEmpty ? input : result;
|
||||
} catch (_) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CssFormatter {
|
||||
final String _src;
|
||||
int _i = 0;
|
||||
int _depth = 0;
|
||||
final StringBuffer _buf = StringBuffer();
|
||||
static const String _ind = ' ';
|
||||
|
||||
_CssFormatter(this._src);
|
||||
|
||||
String format() {
|
||||
while (_i < _src.length) {
|
||||
_step();
|
||||
}
|
||||
return _buf.toString().trim();
|
||||
}
|
||||
|
||||
void _step() {
|
||||
final c = _src[_i];
|
||||
|
||||
// Block comment
|
||||
if (c == '/' && _peek(1) == '*') {
|
||||
_readComment();
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal
|
||||
if (c == '"' || c == "'") {
|
||||
_readString(c);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open brace
|
||||
if (c == '{') {
|
||||
_trimTrailingSpace();
|
||||
_buf.write(' {\n');
|
||||
_depth++;
|
||||
_writeIndent();
|
||||
_i++;
|
||||
_skipWs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close brace
|
||||
if (c == '}') {
|
||||
_trimTrailingSpace();
|
||||
_depth = (_depth - 1).clamp(0, 100);
|
||||
_buf.write('\n');
|
||||
_writeIndent();
|
||||
_buf.write('}\n');
|
||||
_i++;
|
||||
_skipWs();
|
||||
if (_depth > 0 && _i < _src.length) {
|
||||
_buf.writeln();
|
||||
_writeIndent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Semicolon
|
||||
if (c == ';') {
|
||||
_buf.write(';\n');
|
||||
_i++;
|
||||
_skipWs();
|
||||
_writeIndent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitespace normalization
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
final s = _buf.toString();
|
||||
if (s.isNotEmpty) {
|
||||
final last = s[s.length - 1];
|
||||
if (last != ' ' && last != '\n') {
|
||||
_buf.write(' ');
|
||||
}
|
||||
}
|
||||
_i++;
|
||||
return;
|
||||
}
|
||||
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
}
|
||||
|
||||
void _readComment() {
|
||||
_buf.write('/*');
|
||||
_i += 2;
|
||||
while (_i < _src.length) {
|
||||
if (_src[_i] == '*' && _peek(1) == '/') {
|
||||
_buf.write('*/');
|
||||
_i += 2;
|
||||
break;
|
||||
}
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
_buf.writeln();
|
||||
_skipWs();
|
||||
_writeIndent();
|
||||
}
|
||||
|
||||
void _readString(String quote) {
|
||||
_buf.write(quote);
|
||||
_i++;
|
||||
while (_i < _src.length) {
|
||||
final c = _src[_i];
|
||||
if (c == '\\') {
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
if (_i < _src.length) {
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
if (c == quote) break;
|
||||
}
|
||||
}
|
||||
|
||||
void _trimTrailingSpace() {
|
||||
final s = _buf.toString().trimRight();
|
||||
_buf.clear();
|
||||
_buf.write(s);
|
||||
}
|
||||
|
||||
void _skipWs() {
|
||||
while (_i < _src.length &&
|
||||
(_src[_i] == ' ' || _src[_i] == '\t' || _src[_i] == '\n' || _src[_i] == '\r')) {
|
||||
_i++;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeIndent() {
|
||||
_buf.write(_ind * _depth);
|
||||
}
|
||||
|
||||
String? _peek(int offset) {
|
||||
final idx = _i + offset;
|
||||
return idx < _src.length ? _src[idx] : null;
|
||||
}
|
||||
}
|
||||
|
||||
211
lib/utils/js_formatter.dart
Normal file
211
lib/utils/js_formatter.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
class JS {
|
||||
/// 格式化 JavaScript 字符串(适合还原压缩后的 JS)
|
||||
static String pretty(String input) {
|
||||
if (input.trim().isEmpty) return input;
|
||||
try {
|
||||
final result = _JsFormatter(input).format();
|
||||
return result.isEmpty ? input : result;
|
||||
} catch (_) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _JsFormatter {
|
||||
final String _src;
|
||||
int _i = 0;
|
||||
int _depth = 0;
|
||||
final StringBuffer _buf = StringBuffer();
|
||||
static const String _ind = ' ';
|
||||
|
||||
_JsFormatter(this._src);
|
||||
|
||||
String format() {
|
||||
while (_i < _src.length) {
|
||||
_step();
|
||||
}
|
||||
return _buf.toString().trim();
|
||||
}
|
||||
|
||||
void _step() {
|
||||
final c = _src[_i];
|
||||
|
||||
// Line comment
|
||||
if (c == '/' && _peek(1) == '/') {
|
||||
_readLineComment();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block comment
|
||||
if (c == '/' && _peek(1) == '*') {
|
||||
_readBlockComment();
|
||||
return;
|
||||
}
|
||||
|
||||
// Template literal
|
||||
if (c == '`') {
|
||||
_readTemplateLiteral();
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal
|
||||
if (c == '"' || c == "'") {
|
||||
_readString(c);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open brace
|
||||
if (c == '{') {
|
||||
_trimTrailingSpace();
|
||||
_buf.write(' {\n');
|
||||
_depth++;
|
||||
_writeIndent();
|
||||
_i++;
|
||||
_skipWs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close brace
|
||||
if (c == '}') {
|
||||
_trimTrailingSpace();
|
||||
_depth = (_depth - 1).clamp(0, 100);
|
||||
_buf.write('\n');
|
||||
_writeIndent();
|
||||
_buf.write('}');
|
||||
_i++;
|
||||
_skipWs();
|
||||
// Don't add newline if followed by ; , )
|
||||
if (_i < _src.length && _src[_i] != ';' && _src[_i] != ',' && _src[_i] != ')') {
|
||||
_buf.writeln();
|
||||
if (_depth > 0 && _i < _src.length) _writeIndent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Semicolon
|
||||
if (c == ';') {
|
||||
_buf.write(';');
|
||||
_i++;
|
||||
_skipWs();
|
||||
if (_i < _src.length && _src[_i] != '}') {
|
||||
_buf.writeln();
|
||||
_writeIndent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Comma — keep on same line but normalize space after
|
||||
if (c == ',') {
|
||||
_buf.write(', ');
|
||||
_i++;
|
||||
_skipWs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitespace normalization
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
final s = _buf.toString();
|
||||
if (s.isNotEmpty) {
|
||||
final last = s[s.length - 1];
|
||||
if (last != ' ' && last != '\n') {
|
||||
_buf.write(' ');
|
||||
}
|
||||
}
|
||||
_i++;
|
||||
return;
|
||||
}
|
||||
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
}
|
||||
|
||||
void _readLineComment() {
|
||||
while (_i < _src.length && _src[_i] != '\n') {
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
_buf.writeln();
|
||||
_writeIndent();
|
||||
}
|
||||
|
||||
void _readBlockComment() {
|
||||
_buf.write('/*');
|
||||
_i += 2;
|
||||
while (_i < _src.length) {
|
||||
if (_src[_i] == '*' && _peek(1) == '/') {
|
||||
_buf.write('*/');
|
||||
_i += 2;
|
||||
break;
|
||||
}
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
}
|
||||
|
||||
void _readString(String quote) {
|
||||
_buf.write(quote);
|
||||
_i++;
|
||||
while (_i < _src.length) {
|
||||
final c = _src[_i];
|
||||
if (c == '\\') {
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
if (_i < _src.length) {
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
if (c == quote) break;
|
||||
}
|
||||
}
|
||||
|
||||
void _readTemplateLiteral() {
|
||||
_buf.write('`');
|
||||
_i++;
|
||||
while (_i < _src.length) {
|
||||
final c = _src[_i];
|
||||
if (c == '\\') {
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
if (_i < _src.length) {
|
||||
_buf.write(_src[_i]);
|
||||
_i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '`') {
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
break;
|
||||
}
|
||||
_buf.write(c);
|
||||
_i++;
|
||||
}
|
||||
}
|
||||
|
||||
void _trimTrailingSpace() {
|
||||
final s = _buf.toString().trimRight();
|
||||
_buf.clear();
|
||||
_buf.write(s);
|
||||
}
|
||||
|
||||
void _skipWs() {
|
||||
while (_i < _src.length &&
|
||||
(_src[_i] == ' ' || _src[_i] == '\t' || _src[_i] == '\n' || _src[_i] == '\r')) {
|
||||
_i++;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeIndent() {
|
||||
_buf.write(_ind * _depth);
|
||||
}
|
||||
|
||||
String? _peek(int offset) {
|
||||
final idx = _i + offset;
|
||||
return idx < _src.length ? _src[idx] : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@ import 'package:proxypin/ui/component/search/search_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('HighlightTextWidget', () {
|
||||
testWidgets('does not apply root style when language is empty', (tester) async {
|
||||
final controller = SearchTextController();
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: HighlightTextWidget(
|
||||
text: 'plain text body',
|
||||
searchController: controller,
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
final selectable = tester.widget<SelectableText>(find.byType(SelectableText));
|
||||
expect(selectable.style, isNull);
|
||||
|
||||
await _disposeController(tester, controller);
|
||||
});
|
||||
|
||||
testWidgets('keeps syntax highlighting while search is active', (tester) async {
|
||||
final controller = SearchTextController();
|
||||
BuildContext? hostContext;
|
||||
|
||||
Reference in New Issue
Block a user