enrollment-app/lib/presentation/email_template/email_html_preview.dart
2026-07-02 11:06:41 +05:30

348 lines
9.3 KiB
Dart

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:nhancepolicy/presentation/email_template/email_template_models.dart';
import 'package:universal_html/html.dart' as html;
const _caretSelector = '[data-nhance-caret="1"]';
class EmailHtmlPreviewController {
_EmailHtmlPreviewState? _state;
void _bind(_EmailHtmlPreviewState state) => _state = state;
void _unbind(_EmailHtmlPreviewState state) {
if (_state == state) _state = null;
}
bool get isReady => _state?.isReady ?? false;
bool get hasCaretMarker => _state?.hasCaretMarker ?? false;
String get currentHtml => _state?.currentHtml ?? '';
void removeCaretMarker() => _state?.removeCaretMarker();
bool insertSnippetAtCursor(String snippet) =>
_state?.insertSnippetAtCursor(snippet) ?? false;
}
class EmailHtmlPreview extends StatefulWidget {
final String html;
final double? height;
final bool editable;
final EmailHtmlPreviewController? controller;
const EmailHtmlPreview({
super.key,
required this.html,
this.height,
this.editable = false,
this.controller,
});
@override
State<EmailHtmlPreview> createState() => _EmailHtmlPreviewState();
}
class _EmailHtmlPreviewState extends State<EmailHtmlPreview> {
late final String _viewType;
html.IFrameElement? _iframe;
late String _activeHtml;
bool _iframeReady = false;
bool _selectionListenersAttached = false;
bool get isReady => _iframeReady;
bool get hasCaretMarker {
final doc = _iframeDocument;
if (doc == null) return false;
return doc.querySelector(_caretSelector) != null;
}
html.Document? get _iframeDocument {
final iframe = _iframe;
if (iframe == null) return null;
try {
return (iframe as dynamic).contentDocument as html.Document?;
} catch (_) {
return null;
}
}
html.Element? get _iframeBody {
final doc = _iframeDocument;
if (doc == null) return null;
return (doc as dynamic).body as html.Element?;
}
dynamic get _iframeWindow {
final iframe = _iframe;
if (iframe == null) return null;
return (iframe as dynamic).contentWindow;
}
String get currentHtml {
removeCaretMarker();
try {
final root = _iframeDocument?.documentElement;
if (root != null) {
return '<!DOCTYPE html>\n${root.outerHtml}';
}
} catch (_) {}
return _stripCaretMarkers(_activeHtml);
}
@override
void initState() {
super.initState();
_activeHtml = _stripCaretMarkers(widget.html);
widget.controller?._bind(this);
_viewType =
'email-preview-${identityHashCode(this)}-${DateTime.now().microsecondsSinceEpoch}';
if (kIsWeb) {
_registerView();
}
}
@override
void dispose() {
widget.controller?._unbind(this);
super.dispose();
}
String _srcForIframe(String htmlContent) {
final clean = _stripCaretMarkers(htmlContent);
if (!widget.editable) return clean;
return _injectEditableBody(clean);
}
String _injectEditableBody(String htmlContent) {
final bodyTag = RegExp(r'<body([^>]*)>', caseSensitive: false);
if (!bodyTag.hasMatch(htmlContent)) return htmlContent;
return htmlContent.replaceFirstMapped(bodyTag, (match) {
final attrs = match.group(1) ?? '';
if (RegExp('contenteditable', caseSensitive: false).hasMatch(attrs)) {
return match.group(0)!;
}
return '<body$attrs contenteditable="true">';
});
}
String _stripCaretMarkers(String htmlContent) {
return htmlContent.replaceAll(
RegExp(
r'<span[^>]*data-nhance-caret="1"[^>]*>.*?</span>',
caseSensitive: false,
dotAll: true,
),
'',
);
}
void _registerView() {
ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.display = 'block'
..style.pointerEvents = 'auto'
..srcdoc = _srcForIframe(_activeHtml);
if (!widget.editable) {
iframe.setAttribute('sandbox', 'allow-same-origin');
}
iframe.onLoad.listen((_) {
_iframeReady = true;
_selectionListenersAttached = false;
_setupEditableIframe();
});
_iframe = iframe;
return iframe;
});
}
void _setupEditableIframe() {
if (!widget.editable || _selectionListenersAttached) return;
final body = _iframeBody;
if (body == null) return;
body.setAttribute('contenteditable', 'true');
body.style.setProperty('outline', 'none');
body.style.setProperty('cursor', 'text');
// Place an invisible DOM marker at the click position so inserts survive
// focus moving to the sidebar.
body.onMouseUp.listen((_) => _placeCaretMarker());
body.onKeyUp.listen((_) => _placeCaretMarker());
body.onClick.listen((_) => _placeCaretMarker());
_selectionListenersAttached = true;
}
html.Element _createCaretMarker(html.Document doc) {
final marker = doc.createElement('span');
marker.setAttribute('data-nhance-caret', '1');
marker.text = '\u200B';
marker.style.setProperty('font-size', '0');
marker.style.setProperty('line-height', '0');
marker.style.setProperty('display', 'inline');
return marker;
}
void _placeCaretMarker() {
if (!widget.editable || !_iframeReady) return;
final doc = _iframeDocument;
final body = _iframeBody;
if (doc == null || body == null) return;
removeCaretMarker();
final range = _readSelectionRange(body);
if (range == null) return;
try {
final marker = _createCaretMarker(doc);
range.insertNode(marker);
(range as dynamic).setStartAfter(marker);
(range as dynamic).collapse(true);
final win = _iframeWindow;
final sel = win?.getSelection() as html.Selection?;
sel?.removeAllRanges();
sel?.addRange(range);
} catch (_) {}
}
dynamic _readSelectionRange(html.Element body) {
try {
final win = _iframeWindow;
final sel = win?.getSelection() as html.Selection?;
if (sel == null || (sel.rangeCount ?? 0) == 0) return null;
final range = sel.getRangeAt(0);
if (!_nodeInside(body, range.startContainer)) return null;
return (range as dynamic).cloneRange();
} catch (_) {
return null;
}
}
void removeCaretMarker() {
final doc = _iframeDocument;
if (doc == null) return;
final markers = doc.querySelectorAll(_caretSelector).toList();
for (final marker in markers) {
marker.remove();
}
}
bool insertSnippetAtCursor(String snippet) {
if (!widget.editable || !_iframeReady || snippet.trim().isEmpty) {
return false;
}
final doc = _iframeDocument;
if (doc == null) return false;
final marker = doc.querySelector(_caretSelector);
if (marker == null || marker.parent == null) return false;
try {
final parent = marker.parent!;
final tempRange = doc.createRange();
final fragment =
(tempRange as dynamic).createContextualFragment(snippet);
if (fragment == null) return false;
parent.insertBefore(fragment, marker);
marker.remove();
_activeHtml = currentHtml;
return true;
} catch (_) {
return false;
}
}
bool _nodeInside(html.Node ancestor, html.Node node) {
var current = node;
while (true) {
if (current == ancestor) return true;
final parent = current.parentNode;
if (parent == null) return false;
current = parent;
}
}
@override
Widget build(BuildContext context) {
final preview = kIsWeb
? ClipRect(
clipBehavior: Clip.hardEdge,
child: SizedBox.expand(
child: HtmlElementView(viewType: _viewType),
),
)
: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
widget.editable
? 'Editable email preview is available on web.'
: 'Email preview is available on web.',
style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54),
textAlign: TextAlign.center,
),
),
);
final decorated = Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
clipBehavior: Clip.hardEdge,
child: preview,
);
if (widget.height != null) {
return SizedBox(height: widget.height, child: decorated);
}
return decorated;
}
}
String appendHtmlSnippet(String html, String snippet) {
if (html.trim().isEmpty) {
return EmailTemplateData.wrapPlainTextAsHtml(snippet);
}
final lower = html.toLowerCase();
const footerMarkers = [
'<div class="footer"',
"<div class='footer'",
'<div class="footer "',
];
for (final marker in footerMarkers) {
final idx = lower.indexOf(marker);
if (idx != -1) {
return '${html.substring(0, idx)}$snippet${html.substring(idx)}';
}
}
final bodyEnd = lower.lastIndexOf('</body>');
if (bodyEnd == -1) {
return '$html$snippet';
}
return '${html.substring(0, bodyEnd)}$snippet${html.substring(bodyEnd)}';
}