348 lines
9.3 KiB
Dart
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)}';
|
|
}
|