|
|
|
import 'dart:async';
|
|
|
|
import 'dart:math';
|
|
|
|
import 'dart:ui';
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'package:flutter/services.dart'; // 复制功能需要
|
|
|
|
import 'package:win_text_editor/app/providers/editor_provider.dart';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
|
|
|
import 'package:win_text_editor/app/providers/logger.dart';
|
|
|
|
import 'package:win_text_editor/app/utils/file_utils.dart';
|
|
|
|
|
|
|
|
class TextTab extends StatefulWidget {
|
|
|
|
final String tabId;
|
|
|
|
|
|
|
|
const TextTab({super.key, required this.tabId});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<TextTab> createState() => _TextTabState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TextTabState extends State<TextTab> {
|
|
|
|
late TextEditingController _controller;
|
|
|
|
late EditorProvider _provider;
|
|
|
|
late FocusNode _focusNode;
|
|
|
|
|
|
|
|
String _language = 'plaintext';
|
|
|
|
|
|
|
|
bool _showFullContent = false;
|
|
|
|
final int _displayThreshold = 50000; // 显示阈值
|
|
|
|
|
|
|
|
final Map<int, String> _visibleLines = {};
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
final double _lineHeight = 20.0; // 单行高度
|
|
|
|
int _firstVisibleLine = 0;
|
|
|
|
int _lastVisibleLine = 0;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_provider = Provider.of<EditorProvider>(context, listen: false);
|
|
|
|
_controller = TextEditingController(text: _getCurrentContent());
|
|
|
|
_focusNode = FocusNode();
|
|
|
|
_scrollController.addListener(_calculateVisibleLines);
|
|
|
|
_detectLanguage();
|
|
|
|
|
|
|
|
// 初始加载可见区域
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
_calculateVisibleLines();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
void _calculateVisibleLines() {
|
|
|
|
final scrollOffset = _scrollController.offset;
|
|
|
|
final viewportHeight = _scrollController.position.viewportDimension;
|
|
|
|
|
|
|
|
_firstVisibleLine = (scrollOffset / _lineHeight).floor();
|
|
|
|
_lastVisibleLine = ((scrollOffset + viewportHeight) / _lineHeight).ceil();
|
|
|
|
|
|
|
|
// 初始加载时至少加载前 500 行
|
|
|
|
if (scrollOffset == 0) {
|
|
|
|
_lastVisibleLine = min(500, _lastVisibleLine);
|
|
|
|
}
|
|
|
|
|
|
|
|
_preloadLines(); // 预加载附近行
|
|
|
|
}
|
|
|
|
|
|
|
|
void _preloadLines() {
|
|
|
|
final tab = _provider.tabs.firstWhere((t) => t.id == widget.tabId);
|
|
|
|
final lines = tab.fullContent.split('\n');
|
|
|
|
|
|
|
|
// 仅缓存可见区域附近的行
|
|
|
|
final preloadStart = max(0, _firstVisibleLine - 20);
|
|
|
|
final preloadEnd = min(lines.length, _lastVisibleLine + 20);
|
|
|
|
|
|
|
|
for (int i = preloadStart; i < preloadEnd; i++) {
|
|
|
|
_visibleLines[i] = lines[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildLine(int lineIndex) {
|
|
|
|
final tab = _provider.tabs.firstWhere((t) => t.id == widget.tabId);
|
|
|
|
final lineContent = _visibleLines[lineIndex] ?? '';
|
|
|
|
|
|
|
|
return tab.isHighlightEnabled
|
|
|
|
? HighlightView(
|
|
|
|
lineContent,
|
|
|
|
language: _language,
|
|
|
|
textStyle: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
|
|
|
)
|
|
|
|
: Text(lineContent, style: const TextStyle(fontFamily: 'monospace'));
|
|
|
|
}
|
|
|
|
|
|
|
|
String _getCurrentContent() {
|
|
|
|
return _provider.tabs.firstWhere((t) => t.id == widget.tabId).fullContent;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _detectLanguage() {
|
|
|
|
final tab = _provider.tabs.firstWhere((t) => t.id == widget.tabId);
|
|
|
|
final fileName = tab.title.toLowerCase();
|
|
|
|
final content = tab.content.trim();
|
|
|
|
|
|
|
|
if (fileName.endsWith('.dart')) {
|
|
|
|
_language = 'dart';
|
|
|
|
} else if (fileName.endsWith('.java')) {
|
|
|
|
_language = 'java';
|
|
|
|
} else if (fileName.endsWith('.py')) {
|
|
|
|
_language = 'python';
|
|
|
|
} else if (fileName.endsWith('.js')) {
|
|
|
|
_language = 'javascript';
|
|
|
|
} else if (fileName.endsWith('.html')) {
|
|
|
|
_language = 'html';
|
|
|
|
} else if (fileName.endsWith('.css')) {
|
|
|
|
_language = 'css';
|
|
|
|
} else if (fileName.endsWith('.json')) {
|
|
|
|
_language = 'json';
|
|
|
|
} else if (fileName.endsWith('.yaml') || fileName.endsWith('.yml')) {
|
|
|
|
_language = 'yaml';
|
|
|
|
} else if (fileName.endsWith('.xml')) {
|
|
|
|
_language = 'xml';
|
|
|
|
}
|
|
|
|
// 如果扩展名未识别,检查内容特征
|
|
|
|
else if (_isLikelyXml(content)) {
|
|
|
|
_language = 'xml';
|
|
|
|
} else {
|
|
|
|
_language = 'plaintext';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isLikelyXml(String content) {
|
|
|
|
if (content.isEmpty) return false;
|
|
|
|
|
|
|
|
// 简单检查:内容以'<'开头,以'>'结尾
|
|
|
|
final trimmed = content.trim();
|
|
|
|
if (!trimmed.startsWith('<') || !trimmed.endsWith('>')) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 更复杂的检查:包含常见的XML标签模式
|
|
|
|
final xmlTagRegex = RegExp(r'<[^/>]+>.*<\/[^>]+>|<[^/>]+\/>');
|
|
|
|
return xmlTagRegex.hasMatch(content);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void didUpdateWidget(TextTab oldWidget) {
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
if (oldWidget.tabId != widget.tabId) {
|
|
|
|
_visibleLines.clear(); // 修复问题2:清空缓存
|
|
|
|
_controller.text = _getCurrentContent();
|
|
|
|
_detectLanguage();
|
|
|
|
_calculateVisibleLines(); // 重新加载
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_controller.dispose();
|
|
|
|
_focusNode.dispose();
|
|
|
|
_scrollController.dispose(); // 添加这行
|
|
|
|
// 关闭时取消加载
|
|
|
|
if (_provider.isTabLoading(widget.tabId)) {
|
|
|
|
_provider.cancelLoading(widget.tabId);
|
|
|
|
}
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final tab = _provider.tabs.firstWhere((t) => t.id == widget.tabId);
|
|
|
|
final progress = _provider.getLoadedChunks(widget.tabId);
|
|
|
|
final totalChunks = (tab.content.length / 10000).ceil();
|
|
|
|
final lineCount = tab.fullContent.split('\n').length;
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
height: 40,
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.grey[100],
|
|
|
|
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'源文本${tab.content.isEmpty ? '' : ' (${tab.fileName!.isEmpty ? '' : '${tab.fileName},'}${tab.content.length}字符)'}',
|
|
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
|
|
),
|
|
|
|
Row(
|
|
|
|
children: [
|
|
|
|
IconButton(
|
|
|
|
icon: const Icon(Icons.folder_open, size: 20),
|
|
|
|
tooltip: '打开文件',
|
|
|
|
onPressed: () => _openFile(context),
|
|
|
|
),
|
|
|
|
IconButton(
|
|
|
|
icon: const Icon(Icons.content_copy, size: 20),
|
|
|
|
tooltip: '复制内容',
|
|
|
|
onPressed:
|
|
|
|
tab.content.isEmpty ? null : () => _copyToClipboard(context, tab.content),
|
|
|
|
),
|
|
|
|
IconButton(
|
|
|
|
icon: const Icon(Icons.save, size: 20),
|
|
|
|
tooltip: '保存到文件',
|
|
|
|
onPressed: tab.content.isEmpty ? null : () => _saveFile(context, tab.content),
|
|
|
|
),
|
|
|
|
DropdownButton<String>(
|
|
|
|
value: _language,
|
|
|
|
icon: const Icon(Icons.code, size: 20),
|
|
|
|
underline: Container(),
|
|
|
|
onChanged: (String? newValue) {
|
|
|
|
setState(() {
|
|
|
|
_language = newValue!;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
items:
|
|
|
|
<String>[
|
|
|
|
'xml',
|
|
|
|
'plaintext',
|
|
|
|
'dart',
|
|
|
|
'java',
|
|
|
|
'python',
|
|
|
|
'javascript',
|
|
|
|
'html',
|
|
|
|
'css',
|
|
|
|
'json',
|
|
|
|
'yaml',
|
|
|
|
].map<DropdownMenuItem<String>>((String value) {
|
|
|
|
return DropdownMenuItem<String>(value: value, child: Text(value));
|
|
|
|
}).toList(),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (_provider.isTabLoading(widget.tabId))
|
|
|
|
LinearProgressIndicator(value: progress / totalChunks, minHeight: 2),
|
|
|
|
Expanded(
|
|
|
|
child: ListView.builder(
|
|
|
|
controller: _scrollController,
|
|
|
|
itemCount: lineCount,
|
|
|
|
itemExtent: _lineHeight, // 固定行高优化性能
|
|
|
|
itemBuilder: (ctx, index) => _buildLine(index),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _openFile(BuildContext context) async {
|
|
|
|
final filePath = await FileUtils.pickFile(context);
|
|
|
|
if (filePath == null || !mounted) return;
|
|
|
|
|
|
|
|
try {
|
|
|
|
final content = await FileUtils.readFileContent(context, filePath);
|
|
|
|
if (content == null || !mounted) return;
|
|
|
|
|
|
|
|
final fileName = filePath.split('\\').last;
|
|
|
|
|
|
|
|
// 根据文件大小选择加载方式
|
|
|
|
if (content.length > 100000) {
|
|
|
|
// 超过100k使用分块加载
|
|
|
|
await _provider.updateContentInChunks(widget.tabId, content, fileName);
|
|
|
|
} else {
|
|
|
|
await _provider.updateContent(widget.tabId, content, fileName);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
_controller.text = content;
|
|
|
|
_detectLanguage();
|
|
|
|
_showFullContent = content.length <= _displayThreshold;
|
|
|
|
_visibleLines.clear(); // 清空缓存
|
|
|
|
_calculateVisibleLines(); // 重新计算可见行
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
// 错误处理保持不变
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 复制到剪贴板
|
|
|
|
Future<void> _copyToClipboard(BuildContext context, String content) async {
|
|
|
|
await Clipboard.setData(ClipboardData(text: content));
|
|
|
|
if (context.mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制到剪贴板')));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 保存文件模拟功能
|
|
|
|
// 保存文件功能
|
|
|
|
Future<void> _saveFile(BuildContext context, String content) async {
|
|
|
|
try {
|
|
|
|
// 让用户选择保存位置
|
|
|
|
String? outputPath = await FilePicker.platform.saveFile(
|
|
|
|
dialogTitle: '保存文件',
|
|
|
|
fileName: 'untitled.txt',
|
|
|
|
allowedExtensions: ['txt'],
|
|
|
|
type: FileType.any,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (outputPath == null) return;
|
|
|
|
|
|
|
|
final file = File(outputPath);
|
|
|
|
|
|
|
|
// 检查文件是否存在
|
|
|
|
if (await file.exists()) {
|
|
|
|
// 如果存在,显示确认对话框
|
|
|
|
final shouldOverwrite = await showDialog<bool>(
|
|
|
|
context: context,
|
|
|
|
builder:
|
|
|
|
(context) => AlertDialog(
|
|
|
|
title: const Text('文件已存在'),
|
|
|
|
content: const Text('要覆盖现有文件吗?'),
|
|
|
|
actions: [
|
|
|
|
TextButton(
|
|
|
|
onPressed: () => Navigator.pop(context, false),
|
|
|
|
child: const Text('取消'),
|
|
|
|
),
|
|
|
|
TextButton(
|
|
|
|
onPressed: () => Navigator.pop(context, true),
|
|
|
|
child: const Text('覆盖'),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (shouldOverwrite != true) return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 写入文件
|
|
|
|
await file.writeAsString(content);
|
|
|
|
|
|
|
|
if (context.mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存到: ${file.path}')));
|
|
|
|
}
|
|
|
|
} on FileSystemException catch (e) {
|
|
|
|
if (context.mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.message}')));
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
if (context.mounted) {
|
|
|
|
ScaffoldMessenger.of(
|
|
|
|
context,
|
|
|
|
).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}')));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|