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 Color surfaceColor; final ValueChanged? onContentChanged; final ValueChanged? onHeightChanged; final String placeholder; const VditorEditor({ super.key, this.initialContent, required this.noteId, this.isDark = false, this.surfaceColor = Colors.white, this.onContentChanged, this.onHeightChanged, this.placeholder = '使用 Markdown 格式书写...', }); @override State createState() => VditorEditorState(); } class VditorEditorState extends State { InAppWebViewController? _controller; bool _isReady = false; bool _loadFailed = false; late final TextEditingController _fallbackController; String? _distDir; // Windows: 文件系统路径 double _contentHeight = 200; // WebView 内容高度,随内容撑开 double _lastKeyboardH = 0; // 上次键盘高度,用于检测键盘弹出 final Completer _readyCompleter = Completer(); Timer? _fallbackTimer; Future get ready => _readyCompleter.future; bool get isReady => _isReady; @override void initState() { super.initState(); _fallbackController = TextEditingController(text: widget.initialContent ?? ''); _startFallbackTimer(); if (Platform.isWindows) { _locateDistDir(); } else { // Android/iOS: 直接从 asset 加载,无需定位文件系统路径 if (mounted) setState(() {}); } } void _startFallbackTimer() { _fallbackTimer = Timer(const Duration(seconds: 15), () { if (!_isReady && mounted) { setState(() => _loadFailed = true); } }); } /// 定位 vditor_dist 目录(Windows 构建时由 CMakeLists 复制到 data/ 下) Future _locateDistDir() async { try { 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(() {}); 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(); _fallbackController.dispose(); _destroyVditor(); super.dispose(); } Future _destroyVditor() async { if (_controller != null) { try { await _controller!.evaluateJavascript(source: 'destroy()'); } catch (_) {} } } Future 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 setValue(String text) async { if (_controller == null || !_isReady) return; try { final escaped = jsonEncode(text); await _controller!.evaluateJavascript(source: 'setValue($escaped)'); } catch (_) {} } Future setTheme(bool isDark) async { if (_controller == null || !_isReady) return; final theme = isDark ? 'dark' : 'light'; try { await _controller!.evaluateJavascript(source: 'setTheme("$theme")'); } catch (_) {} } Future setBgColor(String hexColor) async { if (_controller == null || !_isReady) return; try { final escaped = jsonEncode(hexColor); await _controller!.evaluateJavascript(source: 'setBgColor($escaped)'); } catch (_) {} } Future insertValue(String text) async { if (_controller == null || !_isReady) return; try { final escaped = jsonEncode(text); await _controller!.evaluateJavascript(source: 'insertValue($escaped)'); } catch (_) {} } Future _scrollToCursor() async { if (_controller == null || !_isReady) return; try { // 延迟一帧让键盘动画完成 await Future.delayed(const Duration(milliseconds: 300)); await _controller!.evaluateJavascript(source: 'scrollToCursor()'); } 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!); } setBgColor(_colorToHex(widget.surfaceColor)); } @override void didUpdateWidget(covariant VditorEditor oldWidget) { super.didUpdateWidget(oldWidget); if (widget.surfaceColor != oldWidget.surfaceColor) { setBgColor(_colorToHex(widget.surfaceColor)); } } static String _colorToHex(Color color) { return '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}'; } Future _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('![]($mdPath)'); } catch (e) { debugPrint('[VditorEditor] pickImage error: $e'); } } Future _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('![]($mdPath)'); } catch (e) { debugPrint('[VditorEditor] handleImageUpload error: $e'); } } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; if (_loadFailed) { return TextField( controller: _fallbackController, 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, ); } // Windows: 等待 dist 目录定位完成 if (Platform.isWindows && _distDir == null) { return Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)); } // Windows: WebView 环境未初始化时,直接 fallback 到纯文本编辑 if (Platform.isWindows && windowsWebViewEnvironment == null) { _loadFailed = true; return TextField( controller: _fallbackController, 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, ); } final String initialUrl; if (Platform.isWindows) { final htmlPath = p.join(_distDir!, 'vditor_editor.html'); initialUrl = 'file:///${htmlPath.replaceAll('\\', '/')}'; } else { // Android: 直接访问 APK 内 asset,相对路径自动解析 initialUrl = 'file:///android_asset/flutter_assets/assets/vditor/dist/vditor_editor.html'; } debugPrint('[VditorEditor] loading: $initialUrl'); // 键盘弹出时,滚动到光标位置 final keyboardH = MediaQuery.of(context).viewInsets.bottom; if (keyboardH > 0 && _lastKeyboardH == 0 && _isReady) { Future.microtask(() => _scrollToCursor()); } _lastKeyboardH = keyboardH; // Windows 桌面端:WebView 填满父容器,自身管理内部滚动 // 移动端:使用 _contentHeight 动态撑开,配合 onHeightChanged 回调 if (Platform.isWindows) { return Stack( children: [ InAppWebView( webViewEnvironment: windowsWebViewEnvironment, initialUrlRequest: URLRequest(url: WebUri(initialUrl)), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, transparentBackground: true, disableContextMenu: false, useHybridComposition: true, allowFileAccessFromFileURLs: true, allowUniversalAccessFromFileURLs: 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()); } }, ); controller.addJavaScriptHandler( handlerName: 'onHeightChanged', callback: (args) { if (args.isNotEmpty) { final h = double.tryParse(args[0].toString()) ?? _contentHeight; if ((h - _contentHeight).abs() > 2 && h > 0) { setState(() => _contentHeight = h); widget.onHeightChanged?.call(h); } } }, ); }, onLoadStop: (controller, url) async { final theme = widget.isDark ? 'dark' : 'light'; final escapedPlaceholder = jsonEncode(widget.placeholder); await controller.evaluateJavascript( source: 'initVditor("$theme", $escapedPlaceholder)', ); }, onReceivedError: (controller, request, error) { debugPrint('[VditorEditor] load error: ${error.description}'); }, ), // 加载动画:Vditor 就绪前显示 if (!_isReady) Container( color: colors.surface, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary), ), const SizedBox(height: 10), Text( '编辑器加载中...', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), ), ], ), ), ), ], ); } return SizedBox( height: _contentHeight, child: Stack( children: [ Container( color: colors.surface, child: InAppWebView( webViewEnvironment: Platform.isWindows ? windowsWebViewEnvironment : null, initialUrlRequest: URLRequest(url: WebUri(initialUrl)), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, transparentBackground: true, disableContextMenu: false, useHybridComposition: true, allowFileAccessFromFileURLs: true, allowUniversalAccessFromFileURLs: 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()); } }, ); controller.addJavaScriptHandler( handlerName: 'onHeightChanged', callback: (args) { if (args.isNotEmpty) { final h = double.tryParse(args[0].toString()) ?? _contentHeight; if ((h - _contentHeight).abs() > 2 && h > 0) { setState(() => _contentHeight = h); widget.onHeightChanged?.call(h); } } }, ); }, onLoadStop: (controller, url) async { final theme = widget.isDark ? 'dark' : 'light'; final escapedPlaceholder = jsonEncode(widget.placeholder); await controller.evaluateJavascript( source: 'initVditor("$theme", $escapedPlaceholder)', ); }, onReceivedError: (controller, request, error) { debugPrint('[VditorEditor] load error: ${error.description}'); }, ), ), // 加载动画:Vditor 就绪前显示 if (!_isReady) Container( color: colors.surface, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary), ), const SizedBox(height: 10), Text( '编辑器加载中...', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), ), ], ), ), ), ], ), ); } }