import 'dart:async'; import 'dart:convert'; import 'dart:ui_web' as ui; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:universal_html/html.dart' as html; class GrapesEmailEditorController { _GrapesEmailEditorState? _state; void _bind(_GrapesEmailEditorState state) => _state = state; void _unbind(_GrapesEmailEditorState state) { if (_state == state) _state = null; } bool get isReady => _state?.isReady ?? false; String get currentHtml => _state?.currentHtml ?? ''; Future exportHtml() => _state?.exportHtml() ?? Future.value(''); void loadHtml(String html) => _state?.loadHtml(html); void insertToken(String token) => _state?.insertToken(token); } class GrapesEmailEditor extends StatefulWidget { final String html; final GrapesEmailEditorController? controller; final ValueChanged? onHtmlChanged; const GrapesEmailEditor({ super.key, required this.html, this.controller, this.onHtmlChanged, }); @override State createState() => _GrapesEmailEditorState(); } class _GrapesEmailEditorState extends State { static int _instanceCounter = 0; late final String _viewType; html.IFrameElement? _iframe; StreamSubscription? _messageSub; bool _isReady = false; bool _isDisposed = false; String _currentHtml = ''; int _requestCounter = 0; final Map> _pendingExports = {}; bool get isReady => _isReady; String get currentHtml => _currentHtml; @override void initState() { super.initState(); _currentHtml = widget.html; widget.controller?._bind(this); _instanceCounter += 1; _viewType = 'grapes-email-editor-$_instanceCounter'; if (kIsWeb) { _registerView(); _messageSub = html.window.onMessage.listen(_onMessage); } } @override void dispose() { _isDisposed = true; _messageSub?.cancel(); _messageSub = null; _pendingExports.clear(); try { _postToEditor({'action': 'destroy'}); _iframe?.src = 'about:blank'; } catch (_) {} widget.controller?._unbind(this); super.dispose(); } void _registerView() { ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { final iframe = html.IFrameElement() ..src = 'email-editor.html' ..style.border = 'none' ..style.width = '100%' ..style.height = '100%' ..style.display = 'block'; _iframe = iframe; return iframe; }); } void _postToEditor(Map payload) { if (_isDisposed) return; final win = _iframe?.contentWindow; if (win == null) return; try { final message = jsonEncode({ 'source': 'nhance-email-editor-host', ...payload, }); (win as dynamic).postMessage(message, '*'); } catch (_) {} } Map? _readPayload(Object? data) { if (data == null) return null; if (data is String) { try { final decoded = jsonDecode(data); if (decoded is Map) { return Map.from(decoded); } } catch (_) { return null; } } if (data is Map) { return Map.from(data); } try { final dynamic raw = data; final source = raw['source']?.toString(); if (source == null) return null; return { 'source': source, 'action': raw['action']?.toString(), 'html': raw['html']?.toString(), 'requestId': raw['requestId'], 'message': raw['message']?.toString(), }; } catch (_) { return null; } } void _onMessage(html.MessageEvent event) { if (_isDisposed || !mounted) return; Map? data; try { data = _readPayload(event.data); } catch (_) { return; } if (data == null || data['source'] != 'nhance-email-editor') return; try { switch (data['action']) { case 'ready': if (_isDisposed || !mounted) return; _isReady = true; loadHtml(_currentHtml); break; case 'change': if (_isDisposed || !mounted) return; final htmlContent = data['html']?.toString() ?? ''; if (htmlContent.isEmpty || htmlContent == _currentHtml) return; _currentHtml = htmlContent; widget.onHtmlChanged?.call(htmlContent); break; case 'html': if (_isDisposed) return; final requestId = int.tryParse(data['requestId']?.toString() ?? ''); if (requestId != null) { _pendingExports.remove(requestId)?.complete( data['html']?.toString() ?? '', ); } break; } } catch (_) {} } void loadHtml(String html) { if (_isDisposed) return; _currentHtml = html; _postToEditor({'action': 'load', 'html': html}); } Future exportHtml() async { if (_isDisposed) return _currentHtml; if (!_isReady) return _currentHtml; final requestId = ++_requestCounter; final completer = Completer(); _pendingExports[requestId] = completer; _postToEditor({'action': 'export', 'requestId': requestId}); try { final result = await completer.future.timeout(const Duration(seconds: 5)); if (!_isDisposed) _currentHtml = result; return result; } catch (_) { return _currentHtml; } finally { _pendingExports.remove(requestId); } } void insertToken(String token) { _postToEditor({'action': 'insertToken', 'token': token}); } @override Widget build(BuildContext context) { if (!kIsWeb) { return Center( child: Padding( padding: const EdgeInsets.all(16), child: Text( 'Drag-and-drop email editor is available on web.', style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54), textAlign: TextAlign.center, ), ), ); } return ClipRect( clipBehavior: Clip.hardEdge, child: SizedBox.expand( child: HtmlElementView(viewType: _viewType), ), ); } }