Skip to content

Commit 198e501

Browse files
authored
Merge pull request #29 from adeeteya/html_support
Html support
2 parents 45fdb9e + 968b4ba commit 198e501

File tree

5 files changed

+472
-3
lines changed

5 files changed

+472
-3
lines changed

lib/home.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import 'package:flutter/material.dart';
77
import 'package:flutter/services.dart';
88
import 'package:markdown_editor/device_preference_notifier.dart';
99
import 'package:markdown_editor/l10n/generated/app_localizations.dart';
10+
import 'package:markdown_editor/widgets/MarkdownBody/custom_text_node.dart';
1011
import 'package:markdown_editor/widgets/MarkdownTextInput/markdown_text_input.dart';
11-
import 'package:markdown_widget/widget/markdown_block.dart';
12+
import 'package:markdown_widget/markdown_widget.dart';
1213

1314
enum MenuItem { switchTheme, switchView, open, clear, save }
1415

@@ -208,6 +209,10 @@ class _HomeState extends State<Home> {
208209
}
209210

210211
Widget _markdownPreviewWidget() {
212+
final isDark = widget.devicePreferenceNotifier.value.isDarkMode;
213+
final config = isDark
214+
? MarkdownConfig.darkConfig
215+
: MarkdownConfig.defaultConfig;
211216
return Card(
212217
child: SizedBox(
213218
height: double.infinity,
@@ -219,7 +224,15 @@ class _HomeState extends State<Home> {
219224
child: SingleChildScrollView(
220225
controller: _scrollController,
221226
padding: const EdgeInsets.all(8),
222-
child: MarkdownBlock(data: _inputText),
227+
child: MarkdownBlock(
228+
data: _inputText,
229+
generator: MarkdownGenerator(
230+
textGenerator: (node, config, visitor) =>
231+
CustomTextNode(node.textContent, config, visitor),
232+
richTextBuilder: Text.rich,
233+
),
234+
config: config,
235+
),
223236
),
224237
),
225238
),
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_svg/flutter_svg.dart';
3+
import 'package:markdown_widget/markdown_widget.dart';
4+
5+
class CustomImgConfig extends ImgConfig {
6+
final bool wrapWithViewer;
7+
8+
final BoxFit rasterFit;
9+
10+
final BoxFit svgFit;
11+
12+
final AlignmentGeometry alignment;
13+
14+
CustomImgConfig({
15+
this.wrapWithViewer = true,
16+
this.rasterFit = BoxFit.cover,
17+
this.svgFit = BoxFit.scaleDown,
18+
this.alignment = Alignment.center,
19+
super.errorBuilder,
20+
}) : super(
21+
builder: (url, attrs) {
22+
double? width;
23+
double? height;
24+
try {
25+
final w = attrs['width'];
26+
final h = attrs['height'];
27+
if (w != null && w.trim().isNotEmpty) width = double.parse(w);
28+
if (h != null && h.trim().isNotEmpty) height = double.parse(h);
29+
} catch (_) {}
30+
31+
final alt = attrs['alt'] ?? '';
32+
final lower = url.toLowerCase();
33+
final isNetwork = url.startsWith('http');
34+
final isSvg =
35+
lower.endsWith('.svg') ||
36+
attrs['type']?.toLowerCase() == 'image/svg+xml';
37+
38+
Widget buildError(Object error, {bool trySvg = false}) {
39+
if (errorBuilder != null) return errorBuilder(url, alt, error);
40+
if (trySvg) {
41+
return isNetwork
42+
? SvgPicture.network(
43+
url,
44+
width: width,
45+
height: height,
46+
fit: svgFit,
47+
alignment: alignment,
48+
clipBehavior: Clip.none,
49+
errorBuilder: (ctx, error, stack) => buildError(error),
50+
)
51+
: SvgPicture.asset(
52+
url,
53+
width: width,
54+
height: height,
55+
fit: svgFit,
56+
alignment: alignment,
57+
clipBehavior: Clip.none,
58+
errorBuilder: (ctx, error, stack) => buildError(error),
59+
);
60+
} else {
61+
return Row(
62+
mainAxisSize: MainAxisSize.min,
63+
children: [
64+
const Icon(
65+
Icons.broken_image,
66+
color: Colors.redAccent,
67+
size: 16,
68+
),
69+
if (alt.isNotEmpty) ...[
70+
const SizedBox(width: 6),
71+
Flexible(child: Text(alt)),
72+
],
73+
],
74+
);
75+
}
76+
}
77+
78+
Widget img;
79+
80+
if (isSvg) {
81+
img = isNetwork
82+
? SvgPicture.network(
83+
url,
84+
width: width,
85+
height: height,
86+
fit: svgFit,
87+
alignment: alignment,
88+
clipBehavior: Clip.none,
89+
errorBuilder: (ctx, error, stack) => buildError(error),
90+
)
91+
: SvgPicture.asset(
92+
url,
93+
width: width,
94+
height: height,
95+
fit: svgFit,
96+
alignment: alignment,
97+
clipBehavior: Clip.none,
98+
errorBuilder: (ctx, error, stack) => buildError(error),
99+
);
100+
} else {
101+
img = isNetwork
102+
? Image.network(
103+
url,
104+
width: width,
105+
height: height,
106+
fit: rasterFit,
107+
alignment: alignment,
108+
errorBuilder: (ctx, error, stack) =>
109+
buildError(error, trySvg: true),
110+
)
111+
: Image.asset(
112+
url,
113+
width: width,
114+
height: height,
115+
fit: rasterFit,
116+
alignment: alignment,
117+
errorBuilder: (ctx, error, stack) =>
118+
buildError(error, trySvg: true),
119+
);
120+
}
121+
122+
if (!wrapWithViewer) return img;
123+
124+
return Builder(
125+
builder: (context) {
126+
return InkWell(
127+
child: Hero(tag: img.hashCode, child: img),
128+
onTap: () async {
129+
await Navigator.of(context).push(
130+
PageRouteBuilder(
131+
opaque: false,
132+
pageBuilder: (_, _, _) => _ImageViewer(child: img),
133+
),
134+
);
135+
},
136+
);
137+
},
138+
);
139+
},
140+
);
141+
}
142+
143+
class _ImageViewer extends StatelessWidget {
144+
final Widget child;
145+
146+
const _ImageViewer({required this.child});
147+
148+
@override
149+
Widget build(BuildContext context) {
150+
return GestureDetector(
151+
onTap: () => Navigator.of(context).pop(),
152+
child: Scaffold(
153+
backgroundColor: Colors.black.toOpacity(0.3),
154+
body: Stack(
155+
fit: StackFit.expand,
156+
children: [
157+
InteractiveViewer(
158+
child: Center(
159+
child: Hero(tag: child.hashCode, child: child),
160+
),
161+
),
162+
Align(
163+
alignment: Alignment.bottomCenter,
164+
child: Padding(
165+
padding: const EdgeInsets.only(bottom: 24),
166+
child: IconButton(
167+
onPressed: () => Navigator.of(context).pop(),
168+
icon: Container(
169+
width: 40,
170+
height: 40,
171+
decoration: BoxDecoration(
172+
color: Colors.white.toOpacity(0.2),
173+
shape: BoxShape.circle,
174+
),
175+
child: const Icon(Icons.clear, color: Colors.grey),
176+
),
177+
),
178+
),
179+
),
180+
],
181+
),
182+
),
183+
);
184+
}
185+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
3+
import 'package:html/dom.dart' as h;
4+
import 'package:html/dom_parsing.dart';
5+
import 'package:html/parser.dart';
6+
import 'package:markdown/markdown.dart' as m;
7+
import 'package:markdown_widget/markdown_widget.dart';
8+
9+
void htmlToMarkdown(h.Node? node, int deep, List<m.Node> mNodes) {
10+
if (node == null) return;
11+
if (node is h.Text) {
12+
mNodes.add(m.Text(node.text));
13+
} else if (node is h.Element) {
14+
final tag = node.localName;
15+
final List<m.Node> children = [];
16+
for (final e in node.children) {
17+
htmlToMarkdown(e, deep + 1, children);
18+
}
19+
m.Element element;
20+
if (tag == MarkdownTag.img.name || tag == 'video') {
21+
element = HtmlElement(tag!, children, node.text);
22+
element.attributes.addAll(node.attributes.cast());
23+
} else {
24+
element = HtmlElement(tag!, children, node.text);
25+
element.attributes.addAll(node.attributes.cast());
26+
}
27+
mNodes.add(element);
28+
}
29+
}
30+
31+
final RegExp tableRep = RegExp(r'<table[^>]*>', multiLine: true);
32+
33+
final RegExp htmlRep = RegExp(r'<[^>]*>', multiLine: true);
34+
35+
///parse [m.Node] to [h.Node]
36+
List<SpanNode> parseHtml(
37+
m.Text node, {
38+
ValueCallback<dynamic>? onError,
39+
WidgetVisitor? visitor,
40+
TextStyle? parentStyle,
41+
}) {
42+
try {
43+
final text = node.textContent.replaceAll(
44+
visitor?.splitRegExp ?? WidgetVisitor.defaultSplitRegExp,
45+
'',
46+
);
47+
if (!text.contains(htmlRep)) return [TextNode(text: node.text)];
48+
final h.DocumentFragment document = parseFragment(text);
49+
return HtmlToSpanVisitor(
50+
visitor: visitor,
51+
parentStyle: parentStyle,
52+
).toVisit(document.nodes.toList());
53+
} catch (e) {
54+
onError?.call(e);
55+
return [TextNode(text: node.text)];
56+
}
57+
}
58+
59+
class HtmlElement extends m.Element {
60+
@override
61+
final String textContent;
62+
63+
HtmlElement(super.tag, super.children, this.textContent);
64+
}
65+
66+
class HtmlToSpanVisitor extends TreeVisitor {
67+
final List<SpanNode> _spans = [];
68+
final List<SpanNode> _spansStack = [];
69+
final WidgetVisitor visitor;
70+
final TextStyle parentStyle;
71+
72+
HtmlToSpanVisitor({WidgetVisitor? visitor, TextStyle? parentStyle})
73+
: visitor = visitor ?? WidgetVisitor(),
74+
parentStyle = parentStyle ?? const TextStyle();
75+
76+
List<SpanNode> toVisit(List<h.Node> nodes) {
77+
_spans.clear();
78+
for (final node in nodes) {
79+
final emptyNode = ConcreteElementNode(style: parentStyle);
80+
_spans.add(emptyNode);
81+
_spansStack.add(emptyNode);
82+
visit(node);
83+
_spansStack.removeLast();
84+
}
85+
final result = List.of(_spans);
86+
_spans.clear();
87+
_spansStack.clear();
88+
return result;
89+
}
90+
91+
@override
92+
void visitText(h.Text node) {
93+
final last = _spansStack.last;
94+
if (last is ElementNode) {
95+
final textNode = TextNode(text: node.text);
96+
last.accept(textNode);
97+
}
98+
}
99+
100+
@override
101+
void visitElement(h.Element node) {
102+
final localName = node.localName ?? '';
103+
final mdElement = m.Element(localName, []);
104+
mdElement.attributes.addAll(node.attributes.cast());
105+
SpanNode spanNode = visitor.getNodeByElement(mdElement, visitor.config);
106+
if (spanNode is! ElementNode) {
107+
final n = ConcreteElementNode(tag: localName, style: parentStyle);
108+
n.accept(spanNode);
109+
spanNode = n;
110+
}
111+
final last = _spansStack.last;
112+
if (last is ElementNode) {
113+
last.accept(spanNode);
114+
}
115+
_spansStack.add(spanNode);
116+
for (final child in node.nodes.toList(growable: false)) {
117+
visit(child);
118+
}
119+
_spansStack.removeLast();
120+
}
121+
}
122+
123+
class CustomTextNode extends ElementNode {
124+
final String text;
125+
final MarkdownConfig config;
126+
final WidgetVisitor visitor;
127+
bool isTable = false;
128+
129+
CustomTextNode(this.text, this.config, this.visitor);
130+
131+
@override
132+
InlineSpan build() {
133+
if (isTable) {
134+
//deal complex table tag with html core widget
135+
return WidgetSpan(child: HtmlWidget(text));
136+
} else {
137+
return super.build();
138+
}
139+
}
140+
141+
@override
142+
void onAccepted(SpanNode parent) {
143+
final textStyle = config.p.textStyle.merge(parentStyle);
144+
children.clear();
145+
if (!text.contains(htmlRep)) {
146+
accept(TextNode(text: text, style: textStyle));
147+
return;
148+
}
149+
//Intercept as table tag
150+
if (text.contains(tableRep)) {
151+
isTable = true;
152+
accept(parent);
153+
return;
154+
}
155+
156+
//The remaining ones are processed by the regular HTML processing.
157+
final spans = parseHtml(
158+
m.Text(text),
159+
visitor: WidgetVisitor(
160+
config: visitor.config,
161+
generators: visitor.generators,
162+
richTextBuilder: visitor.richTextBuilder,
163+
),
164+
parentStyle: parentStyle,
165+
);
166+
for (final element in spans) {
167+
isTable = false;
168+
accept(element);
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)