feat: add CSS and JS formatting support and improve body text handling

This commit is contained in:
wanghongenpin
2026-04-09 05:28:34 +08:00
parent 055910b4be
commit 22fc0d3ba5
6 changed files with 422 additions and 33 deletions

View File

@@ -39,7 +39,7 @@ class HighlightTextWidget extends StatelessWidget {
return SelectableText.rich(
TextSpan(children: spans),
showCursor: true,
selectionColor: highlightSelectionColor(context),
// selectionColor: highlightSelectionColor(context),
contextMenuBuilder: contextMenuBuilder,
);
},

View File

@@ -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);
}

View File

@@ -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);
}

View 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
View 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;
}
}

View File

@@ -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;