generated from dellevin/template
windows版本笔记编辑替换成viditor
This commit is contained in:
@@ -13,6 +13,7 @@ import '../../utils/toast_util.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
import '../../widgets/tag_side_panel.dart';
|
||||
import '../../widgets/vditor_editor.dart';
|
||||
|
||||
class NoteAddPage extends StatefulWidget {
|
||||
final VoidCallback? onCancel;
|
||||
@@ -30,6 +31,7 @@ class _NoteAddPageState extends State<NoteAddPage> {
|
||||
List<String> _images = [];
|
||||
String _editMode = 'edit'; // 'edit' | 'preview'
|
||||
late String _tempId;
|
||||
final _vditorKey = GlobalKey<VditorEditorState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -189,21 +191,32 @@ class _NoteAddPageState extends State<NoteAddPage> {
|
||||
),
|
||||
// 内容编辑
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _contentCtrl,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.6, fontSize: 14),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(
|
||||
hintText: '使用 Markdown 格式书写...',
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6),
|
||||
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
child: isWin
|
||||
? VditorEditor(
|
||||
key: _vditorKey,
|
||||
initialContent: _contentCtrl.text,
|
||||
noteId: _tempId,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
onContentChanged: (value) {
|
||||
_contentCtrl.text = value;
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: TextField(
|
||||
controller: _contentCtrl,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.6, fontSize: 14),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(
|
||||
hintText: '使用 Markdown 格式书写...',
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6),
|
||||
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
// 图片行
|
||||
if (_images.isNotEmpty) _buildImageRow(colors),
|
||||
@@ -513,7 +526,9 @@ class _NoteAddPageState extends State<NoteAddPage> {
|
||||
|
||||
Future<void> _save() async {
|
||||
final title = _titleCtrl.text.trim();
|
||||
final content = _contentCtrl.text.trim();
|
||||
final content = Platform.isWindows
|
||||
? (await _vditorKey.currentState?.getValue() ?? '').trim()
|
||||
: _contentCtrl.text.trim();
|
||||
if (title.isEmpty && content.isEmpty) {
|
||||
ToastUtil.show(context, '标题或内容不能为空');
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../utils/toast_util.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import '../../widgets/tag_side_panel.dart';
|
||||
import '../../widgets/vditor_editor.dart';
|
||||
import 'note_share_page.dart';
|
||||
|
||||
/// 笔记详情页
|
||||
@@ -39,6 +40,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
Timer? _autoSaveTimer;
|
||||
String _saveStatus = '';
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final _vditorKey = GlobalKey<VditorEditorState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -76,7 +78,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
}
|
||||
|
||||
Future<void> _autoSave() async {
|
||||
final content = _contentCtrl.text.trim();
|
||||
final content = Platform.isWindows
|
||||
? (await _vditorKey.currentState?.getValue() ?? '').trim()
|
||||
: _contentCtrl.text.trim();
|
||||
final title = _titleCtrl.text.trim();
|
||||
if (title.isEmpty && content.isEmpty) return;
|
||||
try {
|
||||
@@ -99,7 +103,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
Future<void> _saveEdit() async {
|
||||
_autoSaveTimer?.cancel();
|
||||
final title = _titleCtrl.text.trim();
|
||||
final content = _contentCtrl.text.trim();
|
||||
final content = Platform.isWindows
|
||||
? (await _vditorKey.currentState?.getValue() ?? '').trim()
|
||||
: _contentCtrl.text.trim();
|
||||
if (title.isEmpty && content.isEmpty) {
|
||||
ToastUtil.show(context, '标题或内容不能为空');
|
||||
return;
|
||||
@@ -461,15 +467,26 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
),
|
||||
// 内容编辑
|
||||
Expanded(
|
||||
child: TextField(controller: _contentCtrl, maxLines: null, expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.6, fontSize: 14),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(hintText: '使用 Markdown 格式书写...',
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6),
|
||||
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16)),
|
||||
onChanged: (_) => _onContentChanged()),
|
||||
child: isWin
|
||||
? VditorEditor(
|
||||
key: _vditorKey,
|
||||
initialContent: _contentCtrl.text,
|
||||
noteId: widget.note.id,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
onContentChanged: (value) {
|
||||
_contentCtrl.text = value;
|
||||
_onContentChanged();
|
||||
},
|
||||
)
|
||||
: TextField(controller: _contentCtrl, maxLines: null, expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.6, fontSize: 14),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(hintText: '使用 Markdown 格式书写...',
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6),
|
||||
border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16)),
|
||||
onChanged: (_) => _onContentChanged()),
|
||||
),
|
||||
// 图片网格
|
||||
if (_editImages.isNotEmpty) _buildEditImageGrid(colors),
|
||||
|
||||
261
lib/widgets/vditor_editor.dart
Normal file
261
lib/widgets/vditor_editor.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../main.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
|
||||
class VditorEditor extends StatefulWidget {
|
||||
final String? initialContent;
|
||||
final String noteId;
|
||||
final bool isDark;
|
||||
final ValueChanged<String>? onContentChanged;
|
||||
final String placeholder;
|
||||
|
||||
const VditorEditor({
|
||||
super.key,
|
||||
this.initialContent,
|
||||
required this.noteId,
|
||||
this.isDark = false,
|
||||
this.onContentChanged,
|
||||
this.placeholder = '使用 Markdown 格式书写...',
|
||||
});
|
||||
|
||||
@override
|
||||
State<VditorEditor> createState() => VditorEditorState();
|
||||
}
|
||||
|
||||
class VditorEditorState extends State<VditorEditor> {
|
||||
InAppWebViewController? _controller;
|
||||
bool _isReady = false;
|
||||
bool _loadFailed = false;
|
||||
bool _assetsReady = false;
|
||||
String? _distDir;
|
||||
final Completer<void> _readyCompleter = Completer<void>();
|
||||
Timer? _fallbackTimer;
|
||||
|
||||
Future<void> get ready => _readyCompleter.future;
|
||||
bool get isReady => _isReady;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startFallbackTimer();
|
||||
_locateDistDir();
|
||||
}
|
||||
|
||||
void _startFallbackTimer() {
|
||||
_fallbackTimer = Timer(const Duration(seconds: 15), () {
|
||||
if (!_isReady && mounted) {
|
||||
setState(() => _loadFailed = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 定位 vditor_dist 目录(Windows 构建时由 CMakeLists 复制到 data/ 下)
|
||||
Future<void> _locateDistDir() async {
|
||||
try {
|
||||
// Windows: exe 同级 data/vditor_dist/
|
||||
final exePath = Platform.resolvedExecutable;
|
||||
final exeDir = p.dirname(exePath);
|
||||
final candidate = p.join(exeDir, 'data', 'vditor_dist');
|
||||
if (await File(p.join(candidate, 'vditor_editor.html')).exists()) {
|
||||
_distDir = candidate;
|
||||
if (mounted) setState(() => _assetsReady = true);
|
||||
return;
|
||||
}
|
||||
debugPrint('[VditorEditor] vditor_dist not found at $candidate');
|
||||
if (mounted) setState(() => _loadFailed = true);
|
||||
} catch (e) {
|
||||
debugPrint('[VditorEditor] locateDistDir error: $e');
|
||||
if (mounted) setState(() => _loadFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fallbackTimer?.cancel();
|
||||
_destroyVditor();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _destroyVditor() async {
|
||||
if (_controller != null) {
|
||||
try {
|
||||
await _controller!.evaluateJavascript(source: 'destroy()');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getValue() async {
|
||||
if (_controller == null || !_isReady) return widget.initialContent ?? '';
|
||||
try {
|
||||
final result = await _controller!.evaluateJavascript(source: 'getValue()');
|
||||
return result?.toString() ?? '';
|
||||
} catch (_) {
|
||||
return widget.initialContent ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setValue(String text) async {
|
||||
if (_controller == null || !_isReady) return;
|
||||
try {
|
||||
final escaped = jsonEncode(text);
|
||||
await _controller!.evaluateJavascript(source: 'setValue($escaped)');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> setTheme(bool isDark) async {
|
||||
if (_controller == null || !_isReady) return;
|
||||
final theme = isDark ? 'dark' : 'classic';
|
||||
try {
|
||||
await _controller!.evaluateJavascript(source: 'setTheme("$theme")');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> insertValue(String text) async {
|
||||
if (_controller == null || !_isReady) return;
|
||||
try {
|
||||
final escaped = jsonEncode(text);
|
||||
await _controller!.evaluateJavascript(source: 'insertValue($escaped)');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onVditorReady() {
|
||||
if (_isReady) return;
|
||||
_fallbackTimer?.cancel();
|
||||
_isReady = true;
|
||||
if (!_readyCompleter.isCompleted) _readyCompleter.complete();
|
||||
// 设置初始内容
|
||||
if (widget.initialContent != null && widget.initialContent!.isNotEmpty) {
|
||||
setValue(widget.initialContent!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (image == null) return;
|
||||
final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final targetDir = await ImagePathHelper.instance.getNoteImagesDir(widget.noteId);
|
||||
await ImagePathHelper.instance.ensureDirExists(targetDir);
|
||||
final targetPath = p.join(targetDir, fileName);
|
||||
await File(image.path).copy(targetPath);
|
||||
final mdPath = targetPath.replaceAll('\\', '/');
|
||||
if (mounted) await insertValue('');
|
||||
} catch (e) {
|
||||
debugPrint('[VditorEditor] pickImage error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleImageUpload(String base64Data, String fileName, String mimeType) async {
|
||||
try {
|
||||
final targetDir = await ImagePathHelper.instance.getNoteImagesDir(widget.noteId);
|
||||
await ImagePathHelper.instance.ensureDirExists(targetDir);
|
||||
final base64Str = base64Data.contains(',') ? base64Data.split(',')[1] : base64Data;
|
||||
final bytes = base64Decode(base64Str);
|
||||
final ext = mimeType.contains('png') ? 'png' : 'jpg';
|
||||
final savedName = '${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final targetPath = p.join(targetDir, savedName);
|
||||
await File(targetPath).writeAsBytes(bytes);
|
||||
final mdPath = targetPath.replaceAll('\\', '/');
|
||||
if (mounted) await insertValue('');
|
||||
} catch (e) {
|
||||
debugPrint('[VditorEditor] handleImageUpload error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
if (_loadFailed || !_assetsReady) {
|
||||
if (_loadFailed) {
|
||||
// 离线降级:使用普通 TextField
|
||||
return TextField(
|
||||
controller: TextEditingController(text: widget.initialContent ?? ''),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
strutStyle: const StrutStyle(forceStrutHeight: true, height: 1.6, fontSize: 14),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface, height: 1.6),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.placeholder,
|
||||
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25), height: 1.6),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
onChanged: widget.onContentChanged,
|
||||
);
|
||||
}
|
||||
// 等待 assets 解压
|
||||
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary));
|
||||
}
|
||||
|
||||
final htmlPath = p.join(_distDir!, 'vditor_editor.html');
|
||||
final fileUrl = 'file:///${htmlPath.replaceAll('\\', '/')}';
|
||||
debugPrint('[VditorEditor] loading: $fileUrl');
|
||||
|
||||
return Container(
|
||||
color: colors.surface,
|
||||
child: InAppWebView(
|
||||
webViewEnvironment: windowsWebViewEnvironment,
|
||||
initialUrlRequest: URLRequest(url: WebUri(fileUrl)),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
transparentBackground: true,
|
||||
disableContextMenu: false,
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
_controller = controller;
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onVditorReady',
|
||||
callback: (_) => _onVditorReady(),
|
||||
);
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onContentChanged',
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty) {
|
||||
widget.onContentChanged?.call(args[0].toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onPickImage',
|
||||
callback: (_) => _pickImage(),
|
||||
);
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onImageUpload',
|
||||
callback: (args) {
|
||||
if (args.length >= 3) {
|
||||
_handleImageUpload(args[0].toString(), args[1].toString(), args[2].toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
final theme = widget.isDark ? 'dark' : 'classic';
|
||||
final escapedPlaceholder = jsonEncode(widget.placeholder);
|
||||
await controller.evaluateJavascript(
|
||||
source: 'initVditor("$theme", $escapedPlaceholder)',
|
||||
);
|
||||
},
|
||||
onReceivedError: (controller, request, error) {
|
||||
debugPrint('[VditorEditor] load error: ${error.description}');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user