ollama-app/lib/services/markdown.dart

2465 lines
80 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math' as math;
import 'package:dynamic_system_colors/dynamic_system_colors.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_math_fork/flutter_math.dart' as latex;
import 'package:markdown/markdown.dart' as md;
import 'package:ogp_data_extract/ogp_data_extract.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/hero_text_flight_shuttle_builder.dart';
import 'clients.dart';
import 'haptic.dart';
import 'theme.dart';
typedef Tree = List<md.Node>;
String get exampleMarkdown => r"""
# GitHub Flavored Markdown (GFM) Feature Test
This document demonstrates **every feature of GitHub Flavored Markdown**, including LaTeX math.
---
## 1. Headings
# H1
## H2
### H3
#### H4
##### H5
###### H6
---
## 2. Emphasis
- *Italic*
- *Italic*
- **Bold**
- **Bold**
- ***Bold Italic***
- ~~Strikethrough~~
- ^Superscript^
- ~Subscript~
---
- H~2~O
- E = mc^2^
---
## 3. Blockquotes
> This is a blockquote.
>
> > Nested blockquote.
### Alert
> [!NOTE]
> This is a note alert.
> [!TIP]
> This is a tip alert.
> [!IMPORTANT]
> This is an important alert.
> [!WARNING]
> This is a warning alert.
> [!CAUTION]
> This is a caution alert.
> [!OTHER]
>
> This is an alert with an unknown type.
---
## 4. Lists
### Unordered
* Item 1
* Subitem 1
* Subitem 2
* Item 2
### Ordered
1. First
2. Second
1. Sub-step A
2. Sub-step B
### Task Lists
* [x] Completed task
* [ ] Incomplete task
* [ ] Another incomplete task
---
## 5. Code
### Inline Code
Here is `inline code`.
Single word: `word`
Here is `inline code that will surely get an overflow because` the `viewport is way too small to fit it all in one line and it just keeps going.`
### Color Swatch
- This is red: `#ff0000`
- Purple with alpha: `#80008040`
- Green shorthand: `#0f0`
### Fenced Code Block
```python
print("Hello, world!")
```
```
void hello() {
return "Hello, world!";
}
```
---
## 6. Links
* [GitHub](https://github.com)
* Autolink: https://github.com
* [Relative Link](./README.md)
---
## 7. Images
![A cat](https://cats.con.bz/1920/1080 "Cat Image")
![Another cat](https://cats.con.bz/64/64 "Another Cat")
![](https://cats.con.bz/256/256)
### With link
[![Linked cat](https://cats.con.bz/128/128)](https://github.com)
This is badge: [![](https://img.shields.io/badge/any_text-you_like-blue.png "THE BADGE")](https://google.com)
---
## 8. Tables
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
### Alignment
| Left | Center | Right |
| :--- | :----: | ----: |
| One | Two | Three |
| Four | Five | Six |
### Overflow
| This is a very long header that will surely overflow, in its very own heading even. Incredible! | Centered Header | Right Aligned Header |
| :---------------------------------------------------------------------------------------------- | :-------------: | -------------------: |
| This is a very long cell that will surely overflow and keep going and going and going and going and going and going and going and going | Centered Cell | Right Aligned Cell |
### Images
| ![banner 1: home screen](assets/screenshots/flutter_24.png) | ![banner 2: model selector](assets/screenshots/flutter_15.png) | ![banner 3: sample message](assets/screenshots/flutter_18.png) | ![banner 4: opened sidebar](assets/screenshots/flutter_26.png) |
|-|-|-|-|
---
## 9. Horizontal Rules
---
---
---
---
## 10. Escaping
\*This text is surrounded by literal asterisks\*
---
## 11. Emojis
:smile: :+1: :tada: :heart:
---
## 12. Footnotes
Here is a statement with a footnote.[^1]
[^1]: This is the footnote content.
A second one.[^2]
[^2]: This is a longer footnote that spans multiple lines. It even includes **formatting** and other elements.
And a big one.[^3]
[^3]: This is a
bigger footnote
One with a link.[^4]
[^4]: https://github.com/JHubi1/ollama-app
And a RickRoll![^5]
[^5]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
Other webpage…[^6]
[^6]: https://upsheep.net
---
## 13. Details/Spoiler
<details>
<summary>Click to expand!</summary>
Hidden content inside a collapsible section.
With **formatting**, `inline code`, and even [links](https://github.com).
</details>
<details open>
<summary>Formatting broken</summary>
Because there is *no newline* after the summary tag.
This **won't** be formatted, as expected.
But *this* **will** `be` formatted `correctly`.
With **formatting**, `inline code`, and even [links](https://github.com).
</details>
<details open>
<summary></summary>
Empty title.
</details>
---
## 14. LaTeX Math
Inline: $E = mc^2$
### Block
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
$$
\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
$$
---
## 15. Highlighting
<mark>Highlighted text</mark>
<mark>With *stylized* **content**, `inline code`, and even [links](https://github.com)</mark>
<mark></mark>
---
## 16. Checkbox Interactivity
* [ ] Check me
* [x] Or me
* Me not
---
## 17. Mixed Features
> Combining **bold**, `inline code`, and [links](https://github.com) in a blockquote.
---
End of test document.""";
final class _MarkdownPayload {
final BuildContext context;
final Uri? rootUrl;
final Markdown markdown;
final md.Document document;
final Tree tree;
final Tree sourceTree;
final TextStyle? textStyle;
final TapLongPressGestureRecognizer? recognizer;
_MarkdownPayload({
required this.context,
this.rootUrl,
required this.markdown,
required this.document,
required this.tree,
this.textStyle,
this.recognizer,
}) : sourceTree = List.unmodifiable(tree);
_MarkdownPayload._({
required this.context,
required this.rootUrl,
required this.markdown,
required this.document,
required this.tree,
required this.sourceTree,
required this.textStyle,
required this.recognizer,
});
_MarkdownPayload copy({
required Tree? tree,
TextStyle? textStyle,
TapLongPressGestureRecognizer? recognizer,
}) {
return _MarkdownPayload._(
context: context,
rootUrl: rootUrl,
markdown: markdown,
document: document,
tree: tree ?? [],
sourceTree: sourceTree,
textStyle: textStyle ?? this.textStyle,
recognizer: recognizer ?? this.recognizer,
);
}
Uri resolveUri(String uri) {
if (rootUrl == null) return Uri.parse(uri);
var rootUrlBased = rootUrl!.replace();
if (rootUrlBased.pathSegments.last.isNotEmpty) {
rootUrlBased = rootUrlBased.replace(
pathSegments: [...rootUrlBased.pathSegments, ""],
);
}
while (uri.startsWith("/")) {
uri = uri.substring(1);
}
return rootUrlBased.resolve(uri);
}
}
final class Markdown {
final document = md.Document(
blockSyntaxes: [
LatexBlockSyntax(),
LatexBracketBlockSyntax(),
DetailsBlockSyntax(),
const md.AlertBlockSyntax(),
],
inlineSyntaxes: [
SuperscriptSyntax(),
SubscriptSyntax(),
MarkSyntax(),
LatexInlineSyntax(),
LatexBracketInlineSyntax(),
md.EmojiSyntax(),
md.ColorSwatchSyntax(),
],
extensionSet: md.ExtensionSet.gitHubFlavored,
encodeHtml: false,
);
final String content;
late final Tree tree = document.parse(
content.trim().replaceAll(RegExp(r"<!--.*?-->", dotAll: true), ""),
);
final Uri? _rootUrl;
final TextStyle? _textStyle;
final TapLongPressGestureRecognizer? _recognizer;
Markdown(this.content, {this._rootUrl, this._textStyle, this._recognizer});
late final double _paragraphHeight = (TextPainter(
text: TextSpan(text: "A", style: _textStyle),
textDirection: TextDirection.ltr,
)..layout()).preferredLineHeight;
final _paragraphBreak = const TextSpan(
text: "\n\n",
style: TextStyle(height: 0.85),
);
Widget _imageErrorBuilder(context, _, _) => Padding(
padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
child: Image.asset(
"assets/logo512error.png",
height: _paragraphHeight * 3,
filterQuality: FilterQuality.low,
color: Theme.of(context).colorScheme.onSurface,
),
);
TextStyle _mergeOrOther(TextStyle? style, TextStyle other) =>
style?.merge(other) ?? other;
TextSpan toTextSpan(BuildContext context) =>
TextSpan(children: toInlineSpans(context), style: _textStyle);
List<InlineSpan> toInlineSpans(BuildContext context) => _toInlineSpans(
_MarkdownPayload(
context: context,
rootUrl: _rootUrl,
markdown: this,
document: document,
tree: tree,
textStyle: _textStyle,
recognizer: _recognizer,
),
block: true,
);
List<InlineSpan> _toInlineSpans(
_MarkdownPayload payload, {
bool block = false,
}) {
final context = payload.context;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final codeBox = BoxDecoration(
color: colorScheme.surfaceContainerHigh,
border: Border.all(color: colorScheme.outline),
borderRadius: BorderRadius.circular(6),
);
final spans = <InlineSpan>[];
for (var nodePair in payload.tree.asMap().entries) {
var skipParagraphBreak = false;
final node = nodePair.value;
if (node is md.Text) {
var text = node.text.replaceAll("\n", " ");
while (text.contains(" ")) {
text = text.replaceAll(" ", " ");
}
const nodeStyle = TextStyle(
// height: kTextHeightNone,
fontFeatures: [FontFeature.tabularFigures()],
);
spans.addAll(
text
.split(RegExp(r"(?=\s|\/|\\)"))
.map(
(t) => TextSpan(
text: t,
style: _mergeOrOther(payload.textStyle, nodeStyle),
recognizer: payload.recognizer,
),
),
);
} else if (node is md.Element) {
switch (node.tag) {
case "p": // MARK: Paragraph
final block =
node.children!.length == 1 &&
node.children![0] is md.Element &&
(node.children![0] as md.Element).tag == "img";
node.children![0] = node.children![0] is md.Text
? md.Text((node.children![0] as md.Text).text.trimLeft())
: node.children![0];
spans.addAll(
_toInlineSpans(payload.copy(tree: node.children), block: block),
);
case "br": // MARK: Line Break
skipParagraphBreak = true;
spans.add(const TextSpan(text: "\n"));
case "hr": // MARK: Horizontal Rule
spans.add(const WidgetSpan(child: Divider()));
case "h1" || "h2" || "h3" || "h4" || "h5" || "h6": // MARK: Headings
final multiplier =
(payload.textStyle?.fontSize ?? kDefaultFontSize) /
kDefaultFontSize;
final size =
switch (node.tag) {
// stolen from HTML
"h1" => 32,
"h2" => 24,
"h3" => 18.72,
"h4" => 16,
"h5" => 13.28,
"h6" => 10.72,
_ => kDefaultFontSize,
} -
2;
final nodeStyle = TextStyle(
fontSize: multiplier * size,
fontWeight: FontWeight.bold,
);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
),
),
);
case "em": // MARK: Italic
const nodeStyle = TextStyle(fontStyle: FontStyle.italic);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
),
),
);
case "strong": // MARK: Bold
const nodeStyle = TextStyle(fontWeight: FontWeight.bold);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
),
),
);
case "del": // MARK: Strikethrough
const nodeStyle = TextStyle(decoration: TextDecoration.lineThrough);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
),
),
);
case "mark": // MARK: Highlight
final color = Colors.amberAccent
.harmonizeWith(colorScheme.primary)
.withValues(alpha: 0.6);
final nodeStyle = TextStyle(
color: color.computeLuminance() > 0.5
? Colors.black
: Colors.white,
backgroundColor: color,
);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
),
),
);
case "a": // MARK: Link
final href = node.attributes["href"];
final parsed = href == null ? null : Uri.tryParse(href);
final valid =
href != null &&
parsed != null &&
["http", "https"].contains(parsed.scheme);
final recognizer = valid
? TapLongPressGestureRecognizer(
onTap: () => launchUrl(payload.resolveUri(href)),
onLongPress: () {
Clipboard.setData(ClipboardData(text: href));
if (!kIsWeb) lightHaptic();
},
)
: null;
final linkColor = Colors.lightBlue.harmonizeWith(
colorScheme.primary,
);
final nodeStyle = TextStyle(
color: valid ? linkColor : theme.disabledColor,
decoration: TextDecoration.underline,
decorationThickness: 1.2,
decorationColor: linkColor,
fontStyle: valid ? null : FontStyle.italic,
);
spans.addAll(
_toInlineSpans(
payload.copy(
tree: node.children,
textStyle: _mergeOrOther(payload.textStyle, nodeStyle),
recognizer: recognizer,
),
),
);
case "sub": // MARK: Subscript/Superscript
case "sup":
final isSup = node.tag == "sup";
final referenceTo =
isSup && node.attributes["class"] == "footnote-ref"
? (node.children!.first as md.Element).attributes["href"]
?.substring(1)
: null;
if (referenceTo != null) {
md.Element? referenced;
try {
referenced =
payload.sourceTree.firstWhere(
(e) =>
e is md.Element &&
e.attributes["class"] == "footnotes",
)
as md.Element;
referenced = referenced =
referenced.children!.first as md.Element;
referenced =
referenced.children!.firstWhere(
(e) =>
e is md.Element &&
e.tag == "li" &&
e.attributes["id"] == referenceTo,
)
as md.Element;
} catch (_) {}
if (referenced == null) break;
final referencedTree = List<md.Element>.from(
referenced.children!,
);
if (referencedTree.isEmpty) break;
referencedTree.last = md.Element(
referencedTree.last.tag,
referencedTree.last.children!.sublist(
0,
referencedTree.last.children!.length - 2,
),
);
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Builder(
builder: (context) {
final nodeStyle = TextStyle(
color: colorScheme.onSurface,
fontSize: math.max(
8.0,
(DefaultTextStyle.of(context).style.fontSize ??
Theme.of(
context,
).textTheme.bodyMedium?.fontSize ??
14) -
4,
),
fontWeight: FontWeight.w500,
height: kTextHeightNone,
);
return GestureDetector(
onTap: () {
if (!kIsWeb) lightHaptic();
showModalBottomSheet(
context: context,
builder: (_) => _MarkdownFootnoteModal(
payload: payload.copy(tree: referencedTree),
),
);
},
child: Transform.translate(
offset: const Offset(0, -4),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border.all(color: colorScheme.outline),
borderRadius: BorderRadius.circular(64),
),
padding: const EdgeInsets.symmetric(horizontal: 5),
child: Text(
referenceTo.substring(3),
style: _mergeOrOther(
payload.textStyle,
nodeStyle,
),
),
),
),
);
},
),
),
);
break;
}
spans.addAll(
_toInlineSpans(payload.copy(tree: node.children)).map(
(s) => WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Builder(
builder: (context) {
final nodeStyle = TextStyle(
fontSize: math.max(
8.0,
(DefaultTextStyle.of(context).style.fontSize ??
theme.textTheme.bodyMedium?.fontSize ??
14) -
4,
),
fontWeight: FontWeight.w500,
);
return Transform.translate(
offset: Offset(0, 4 * (isSup ? -1 : 1)),
child: Text.rich(
s,
style: _mergeOrOther(payload.textStyle, nodeStyle),
),
);
},
),
),
),
);
case "code": // MARK: Inline code
final text = (node.children!.first as md.Text).text;
final backgroundColor =
node.attributes.containsKey("class") &&
node.attributes["class"] == "gfm-color_chip"
? (node.children!.firstWhere(
(e) => e is md.Element && e.tag == "span",
)
as md.Element)
.attributes["style"]
: null;
final colorCode =
backgroundColor != null &&
backgroundColor.startsWith("background-color:")
? backgroundColor
.substring(17)
.trim()
.replaceAll(RegExp(r"^#|;$"), "")
: null;
final color = colorCode != null
? switch (colorCode.length) {
3 || 4 => () {
final r = int.tryParse(colorCode[0] * 2, radix: 16);
final g = int.tryParse(colorCode[1] * 2, radix: 16);
final b = int.tryParse(colorCode[2] * 2, radix: 16);
final a = int.tryParse(
(colorCode.length == 4 ? colorCode[3] : "F") * 2,
radix: 16,
);
return Color.fromARGB(a ?? 255, r ?? 0, g ?? 0, b ?? 0);
}(),
6 || 8 => () {
final r = int.tryParse(
colorCode.substring(0, 2),
radix: 16,
);
final g = int.tryParse(
colorCode.substring(2, 4),
radix: 16,
);
final b = int.tryParse(
colorCode.substring(4, 6),
radix: 16,
);
final a = int.tryParse(
(colorCode.length == 8
? colorCode.substring(6, 8)
: "FF"),
radix: 16,
);
return Color.fromARGB(a ?? 255, r ?? 0, g ?? 0, b ?? 0);
}(),
_ => null,
}
: null;
final textStyle = _mergeOrOther(
payload.textStyle,
TextStyle(
fontFamily: "GoogleSansCode",
fontSize: 14,
backgroundColor: Colors.transparent,
color:
theme.textTheme.bodyMedium?.color ??
theme.colorScheme.onSurface,
),
);
final parts = <Widget>[
...text
.split(RegExp(r"(?<=\s|\/|\\)"))
.map((t) => Text(t, style: textStyle)),
];
if (color != null) {
parts.add(
Container(
height: _paragraphHeight - 1,
margin: const EdgeInsets.all(3),
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
border: Border.all(color: theme.colorScheme.outline),
shape: BoxShape.circle,
),
child: const SizedBox.square(dimension: 10),
),
),
);
}
for (var w in parts.asMap().entries) {
final isStart = w.key == 0;
final isEnd = w.key == parts.length - 1;
final decoration = BoxDecoration(
color: codeBox.color,
// border: Border(
// top: codeBox.border!.top,
// bottom: codeBox.border!.bottom,
// left: isStart
// ? (codeBox.border! as Border).left
// : BorderSide.none,
// right: isEnd
// ? (codeBox.border! as Border).right
// : BorderSide.none,
// ),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
isStart
? (codeBox.borderRadius! as BorderRadius).topLeft.x
: 0,
),
bottomLeft: Radius.circular(
isStart
? (codeBox.borderRadius! as BorderRadius).bottomLeft.x
: 0,
),
topRight: Radius.circular(
isEnd
? (codeBox.borderRadius! as BorderRadius).topRight.x
: 0,
),
bottomRight: Radius.circular(
isEnd
? (codeBox.borderRadius! as BorderRadius).bottomRight.x
: 0,
),
),
);
final padding = EdgeInsets.only(
left: isStart ? 2 : 0,
right: isEnd ? 2 : 0,
);
spans.addAll([
if (w.key != 0)
TextSpan(
text: "\u{200B}",
style: TextStyle(
height: kTextHeightNone,
backgroundColor: decoration.color,
),
spellOut: color != null,
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
decoration: decoration,
padding: padding,
child: w.value,
),
),
]);
}
case "pre": // MARK: Fenced code block
final codeElement = node.children!.first as md.Element;
final text = (codeElement.children!.first as md.Text).text.trim();
final lang =
codeElement.attributes.containsKey("class") &&
codeElement.attributes["class"]!.startsWith("language-")
? codeElement.attributes["class"]!.substring(9)
: null;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Stack(
children: [
SizedBox(
width: double.infinity,
child: Container(
decoration: codeBox,
padding: const EdgeInsets.all(2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
).copyWith(right: 24),
child: HighlightView(
text,
language: lang,
autoDetection: true,
theme: (theme.brightness == Brightness.light)
? preHighlightGithubThemeLight
: preHighlightGithubThemeDark,
padding: EdgeInsets.zero,
textStyle: const TextStyle(
height: kTextHeightNone,
),
),
),
),
),
),
Align(
alignment: Alignment.topRight,
child: Transform.translate(
offset: const Offset(-2, 2),
child: Container(
decoration: BoxDecoration(
color: codeBox.color,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(2),
topRight: Radius.circular(2),
),
),
height: 34.6,
width: 34.6,
child: IconButton(
padding: EdgeInsets.zero,
onPressed: () {
Clipboard.setData(ClipboardData(text: text));
if (!kIsWeb) lightHaptic();
},
icon: Icon(Icons.copy, size: _paragraphHeight),
),
),
),
),
],
),
style: const TextStyle(),
),
);
case "img": // MARK: Image
final alt = (node.attributes["alt"] ?? "").trim();
final title = node.attributes["title"]?.trim();
var src = node.attributes["src"];
void imageScreen(String src) => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _MarkdownImageScreen(
src: src,
imageErrorBuilder: _imageErrorBuilder,
title: title,
),
),
);
if (src != null) {
src = payload.resolveUri(src).toString();
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Semantics(
image: true,
label: alt.isEmpty ? "Unlabeled image" : alt,
child: InkWell(
onTap: payload.recognizer != null
? payload.recognizer!.onTap
: () => imageScreen(src!),
onLongPress: payload.recognizer != null
? () {
payload.recognizer!.onLongPress?.call();
}
: null,
onDoubleTap: payload.recognizer != null
? () => imageScreen(src!)
: null,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: colorScheme.outline),
borderRadius: BorderRadius.circular(6),
color: colorScheme.surfaceContainer,
),
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Hero(
tag: src,
child: Image.network(
src,
filterQuality: FilterQuality.medium,
fit: BoxFit.scaleDown,
errorBuilder: _imageErrorBuilder,
),
),
if (title != null && block) ...[
const Divider(height: 1),
ColoredBox(
color: colorScheme.surfaceContainerHigh,
child: ListTile(
title: Hero(
tag: title,
createRectTween: heroTextCreateRectTween,
flightShuttleBuilder:
heroTextFlightShuttleBuilder,
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
dense: true,
),
),
],
],
),
),
),
),
),
style: const TextStyle(),
),
);
} else {
spans.add(
TextSpan(
text: alt,
style: _mergeOrOther(
payload.textStyle,
const TextStyle(fontStyle: FontStyle.italic),
),
),
);
}
case "blockquote": // MARK: Blockquote
case "div"
when node.attributes["class"]
?.split(" ")
.contains("markdown-alert") ??
false:
var alertType =
node.attributes.containsKey("class") &&
(node.attributes["class"]?.split(" ").length ?? 0) >= 2 &&
node.attributes["class"]!.startsWith("markdown-alert")
? node.attributes["class"]!
.split(" ")[1]
.replaceFirst(RegExp("^markdown-alert-"), "")
: null;
if (![
"note",
"tip",
"important",
"warning",
"caution",
].contains(alertType)) {
alertType = null;
}
final alertTitle = alertType != null
? node.children!.removeAt(
node.children!.indexWhere(
(e) =>
e is md.Element &&
e.attributes["class"] == "markdown-alert-title",
),
)
as md.Element
: null;
final alertIcon = alertType != null
? switch (alertType) {
"note" => Icons.info_outline,
"tip" => Icons.lightbulb_outline,
"important" => Icons.feedback_outlined,
"warning" => Icons.warning_amber_rounded,
"caution" => Icons.report_outlined,
_ => null,
}
: null;
final alertColor = alertType != null
? (switch (alertType) {
"note" => Colors.blue[600],
"tip" => Colors.green[600],
"important" => Colors.purple[400],
"warning" => Colors.amber[800],
"caution" => Colors.red[700],
_ => null,
})?.harmonizeWith(colorScheme.primary)
: null;
final contentSpans = _toInlineSpans(
payload.copy(tree: node.children),
block: true,
);
if (alertType != null && alertTitle != null) {
final titleSpans = _toInlineSpans(
payload.copy(tree: alertTitle.children),
);
contentSpans.insertAll(0, [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(alertIcon!, size: 18, color: alertColor!),
const SizedBox(width: 6),
Flexible(
child: Text.rich(
TextSpan(children: titleSpans),
style: TextStyle(
fontWeight: FontWeight.bold,
color: alertColor,
height: kTextHeightNone,
),
),
),
],
),
),
_paragraphBreak,
]);
}
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: alertColor ?? colorScheme.outline,
width: 3,
),
),
),
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
margin: const EdgeInsets.only(left: 1.5),
child: Text.rich(TextSpan(children: contentSpans)),
),
),
);
case "table": // MARK: Table
final head =
node.children!.firstWhere(
(e) => e is md.Element && e.tag == "thead",
)
as md.Element;
final body =
node.children!.firstWhere(
(e) => e is md.Element && e.tag == "tbody",
orElse: () => md.Element("tbody", []),
)
as md.Element;
spans.add(
WidgetSpan(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
showCheckboxColumn: false,
border: TableBorder.all(
color: colorScheme.outline,
borderRadius: BorderRadius.circular(6),
),
dividerThickness: 0.4,
headingRowColor: WidgetStatePropertyAll(
colorScheme.surfaceContainerLow,
),
columns: (head.children!.first as md.Element).children!.map(
(e) {
e as md.Element;
final align = switch (e.attributes["align"]) {
"left" => MainAxisAlignment.start,
"right" => MainAxisAlignment.end,
_ => MainAxisAlignment.center,
};
return DataColumn(
label: Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Text.rich(
TextSpan(
children: _toInlineSpans(
payload.copy(tree: e.children),
),
),
overflow: TextOverflow.visible,
),
),
),
headingRowAlignment: align,
);
},
).toList(),
rows: body.children!.map((e) {
return DataRow(
cells: (e as md.Element).children!.map((c) {
c as md.Element;
final align = switch (c.attributes["align"]) {
"right" => Alignment.centerRight,
"center" => Alignment.center,
_ => Alignment.centerLeft,
};
return DataCell(
Align(
alignment: align,
child: Text.rich(
TextSpan(
children: _toInlineSpans(
payload.copy(tree: c.children),
),
),
overflow: TextOverflow.visible,
),
),
);
}).toList(),
color: const WidgetStatePropertyAll(Colors.transparent),
);
}).toList(),
),
),
),
);
case "ol": // MARK: Ordered/Unordered List
case "ul":
final ordered = node.tag == "ol";
final checkboxes = <int, bool>{};
final elements = <InlineSpan>[];
for (var child in (node.children ?? []).asMap().entries) {
if (child.value is md.Element &&
(child.value as md.Element).tag == "li") {
final childElement = child.value as md.Element;
final children = childElement.children ?? [];
if (!ordered &&
childElement.attributes.containsKey("class") &&
childElement.attributes["class"] == "task-list-item") {
final checkbox =
childElement.children!.firstWhere(
(e) =>
e is md.Element &&
e.tag == "input" &&
e.attributes["type"] == "checkbox",
)
as md.Element;
final isChecked = checkbox.attributes.containsKey("checked");
checkboxes[child.key] = isChecked;
children.removeAt(0);
}
elements.add(
TextSpan(
children: _toInlineSpans(payload.copy(tree: children)),
),
);
}
}
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Column(
mainAxisSize: MainAxisSize.min,
children: elements.asMap().entries.map((e) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: _paragraphHeight * 2,
margin: EdgeInsets.only(right: _paragraphHeight / 2),
child: Align(
alignment: Alignment.topRight,
child: (e.value is! TextSpan)
? const SizedBox.shrink()
: checkboxes.containsKey(e.key)
? Transform.translate(
offset: const Offset(6, 1),
child: Container(
height: _paragraphHeight,
width: _paragraphHeight,
margin: const EdgeInsets.only(right: 4),
child: Transform.scale(
scale: 0.8,
child: Checkbox(
value: checkboxes[e.key],
onChanged: null,
),
),
),
)
: Transform.translate(
offset: const Offset(0, 2),
child: ExcludeSemantics(
child: Text(
ordered ? "${e.key + 1}." : "",
style: TextStyle(
height:
payload.textStyle?.height ??
kTextHeightNone,
),
),
),
),
),
),
Expanded(child: Text.rich(e.value)),
],
);
}).toList(),
),
style: const TextStyle(),
),
);
case "details": // MARK: Details
final opened =
node.attributes.containsKey("class") &&
node.attributes["class"]!
.split(" ")
.contains("markdown-details-open");
final summary =
(node.children!.removeAt(
node.children!.indexWhere(
(e) =>
e is md.Element &&
e.attributes["class"] ==
"markdown-details-summary",
),
)
as md.Element)
.children!
.first
as md.Text;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.top,
child: SizedBox(
width: double.infinity,
child: _MarkdownDetails(
initialOpened: opened,
summary: summary.text,
contentSpans: _toInlineSpans(
payload.copy(tree: node.children),
block: true,
),
textStyle: payload.textStyle,
paragraphHeight: _paragraphHeight,
),
),
),
);
case "latex": // MARK: LaTeX Block
final text = (node.children!.first as md.Text).text;
final errorStyle = TextStyle(
color: colorScheme.error,
fontStyle: FontStyle.italic,
);
spans.add(
WidgetSpan(
child: Container(
width: double.infinity,
alignment: Alignment.center,
child: GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
if (!kIsWeb) lightHaptic();
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.all(4),
child: latex.Math.tex(
text,
mathStyle: latex.MathStyle.display,
onErrorFallback: (e) => Text(
"LaTeX Error: ${e.message}",
style: _mergeOrOther(payload.textStyle, errorStyle),
),
),
),
),
),
),
),
);
case "latexInline": // MARK: LaTeX Inline
final text = (node.children!.first as md.Text).text;
final errorStyle = TextStyle(
color: colorScheme.error,
fontStyle: FontStyle.italic,
);
spans.addAll(
latex.Math.tex(
text,
mathStyle: latex.MathStyle.text,
onErrorFallback: (e) => Text(
"LaTeX Error: ${e.message}",
style: _mergeOrOther(payload.textStyle, errorStyle),
),
).texBreak().parts.map(
(m) => WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: SizedBox(child: m),
),
),
);
case "section":
// ignore block, it's used in footnotes
skipParagraphBreak = true;
default:
if (kDebugMode) print(node.toInfoString());
spans.addAll(_toInlineSpans(payload.copy(tree: node.children)));
}
}
if (block &&
!skipParagraphBreak &&
nodePair.key != payload.tree.length - 1) {
spans.add(_paragraphBreak);
}
}
return spans;
}
}
class SuperscriptSyntax extends md.InlineSyntax {
SuperscriptSyntax() : super(r"\^.+?\^");
@override
bool onMatch(md.InlineParser parser, Match match) {
var text = match[0]!.substring(1);
text = text.substring(0, text.length - 1).trim();
if (text.isEmpty) {
parser.addNode(md.Text(match[0]!));
} else {
parser.addNode(md.Element("sup", [md.Text(text)]));
}
return true;
}
}
class SubscriptSyntax extends md.InlineSyntax {
SubscriptSyntax() : super(r"(?<!~)~[^~]+~(?!~)");
@override
bool onMatch(md.InlineParser parser, Match match) {
var text = match[0]!.substring(1);
text = text.substring(0, text.length - 1).trim();
if (text.isEmpty) {
parser.addNode(md.Text(match[0]!));
} else {
parser.addNode(md.Element("sub", [md.Text(text)]));
}
return true;
}
}
class MarkSyntax extends md.InlineSyntax {
MarkSyntax() : super(r"<\s*mark\s*>(.*?)<\/\s*mark\s*>");
@override
bool onMatch(md.InlineParser parser, Match match) {
final text = match[1]!.trim();
if (text.isEmpty) {
parser.addNode(md.Text(match[0]!));
} else {
parser.addNode(md.Element("mark", parser.document.parseInline(text)));
}
return true;
}
}
class LatexBlockSyntax extends md.BlockSyntax {
@override
md.Node? parse(md.BlockParser parser) {
final opening = parser.current.content;
final m = pattern.firstMatch(opening)!;
final after = opening.substring(m.end);
parser.advance();
final buf = StringBuffer();
final closeSame = after.indexOf(_pattern);
if (closeSame != -1) {
buf.write(after.substring(0, closeSame));
return _toElement(buf);
}
if (after.isNotEmpty) buf.write(after);
while (!parser.isDone) {
final line = parser.current.content;
final ci = line.indexOf(_pattern);
if (ci != -1) {
buf.write(line.substring(0, ci));
parser.advance();
return _toElement(buf);
}
buf.writeln(line);
parser.advance();
}
return _toElement(buf);
}
md.Element _toElement(StringBuffer buf) =>
md.Element("latex", [md.Text(buf.toString())]);
@override
RegExp get pattern => RegExp(RegExp.escape(_pattern));
final _pattern = r"$$";
}
class LatexInlineSyntax extends md.InlineSyntax {
LatexInlineSyntax() : super(r"\$.+?\$");
@override
bool onMatch(md.InlineParser parser, Match match) {
var text = match[0]!.substring(1);
text = text.substring(0, text.length - 1).trim();
if (text.isEmpty || [r"$", r"$$"].contains(text)) {
parser.addNode(md.Text(match[0]!));
} else {
parser.addNode(md.Element("latexInline", [md.Text(text)]));
}
return true;
}
}
class LatexBracketBlockSyntax extends md.BlockSyntax {
@override
md.Node? parse(md.BlockParser parser) {
final opening = parser.current.content;
final m = pattern.firstMatch(opening)!;
final after = opening.substring(m.end);
parser.advance();
final buf = StringBuffer();
final closeSame = after.indexOf(_closePattern);
if (closeSame != -1) {
buf.write(after.substring(0, closeSame));
return _toElement(buf);
}
if (after.isNotEmpty) buf.write(after);
while (!parser.isDone) {
final line = parser.current.content;
final ci = line.indexOf(_closePattern);
if (ci != -1) {
buf.write(line.substring(0, ci));
parser.advance();
return _toElement(buf);
}
buf.writeln(line);
parser.advance();
}
return _toElement(buf);
}
md.Element _toElement(StringBuffer buf) =>
md.Element("latex", [md.Text(buf.toString())]);
@override
RegExp get pattern => RegExp(r"^\\\[");
final _closePattern = r"\]";
}
class LatexBracketInlineSyntax extends md.InlineSyntax {
LatexBracketInlineSyntax() : super(r"\\\(.+?\\\)");
@override
bool onMatch(md.InlineParser parser, Match match) {
var text = match[0]!.substring(2);
text = text.substring(0, text.length - 2).trim();
if (text.isEmpty) {
parser.addNode(md.Text(match[0]!));
} else {
parser.addNode(md.Element("latexInline", [md.Text(text)]));
}
return true;
}
}
class DetailsBlockSyntax extends md.BlockSyntax {
@override
md.Node? parse(md.BlockParser parser) {
final opening = parser.current.content;
final m = pattern.firstMatch(opening)!;
final after = opening.substring(m.end);
parser.advance();
final buf = StringBuffer();
final closeSame = after.indexOf(_closePattern);
if (closeSame != -1) {
buf.write(after.substring(0, closeSame));
return _toElement(parser.document, buf);
}
if (after.isNotEmpty) buf.write(after);
while (!parser.isDone) {
final line = parser.current.content;
final ci = line.indexOf(_closePattern);
if (ci != -1) {
buf.write(line.substring(0, ci));
parser.advance();
return _toElement(parser.document, buf);
}
buf.writeln(line);
parser.advance();
}
return _toElement(parser.document, buf);
}
md.Element? _toElement(md.Document doc, StringBuffer buf) {
final m = _realPattern.firstMatch("<details$buf</details>");
if (m == null) return null;
var className = "markdown-details";
if (m.namedGroup("argument") != null) {
final argument = m.namedGroup("argument")!.trim();
if (argument.isNotEmpty && ["open"].contains(argument)) {
className += " markdown-details-$argument";
}
}
var summary = m.namedGroup("summary")!.trim();
while (summary.contains("\n")) {
summary = summary.replaceAll("\n", " ");
}
while (summary.contains(" " * 2)) {
summary = summary.replaceAll(" " * 2, " " * 1);
}
final content = m.namedGroup("content")!.trimRight().split("\n\n");
final contentFirstLine = content.removeAt(0);
final contentNodes = <md.Node>[
md.Element("p", [md.Text(summary)])
..attributes["class"] = "markdown-details-summary",
if (contentFirstLine.trim().isNotEmpty)
md.Element("p", [md.Text(contentFirstLine.trim())]),
...doc.parse(content.join("\n\n").trim()),
];
return md.Element("details", contentNodes)..attributes["class"] = className;
}
@override
RegExp get pattern => RegExp(r"^<\s*details");
final _closePattern = RegExp(r"<\/details\s*>");
final _realPattern = RegExp(
r"<\s*details\s*(?<argument>.*?)\s*>\s*<\s*summary\s*>(?<summary>.*?)<\/summary\s*>(?<content>.*?)<\/details>",
dotAll: true,
);
}
const preHighlightGithubThemeLight = {
"root": TextStyle(
color: Color(0xff24292e),
backgroundColor: Colors.transparent,
),
"comment": TextStyle(color: Color(0xff6a737d), fontStyle: FontStyle.italic),
"quote": TextStyle(color: Color(0xff6a737d), fontStyle: FontStyle.italic),
"keyword": TextStyle(color: Color(0xffd73a49), fontWeight: FontWeight.bold),
"selector-tag": TextStyle(
color: Color(0xffd73a49),
fontWeight: FontWeight.bold,
),
"subst": TextStyle(color: Color(0xff24292e), fontWeight: FontWeight.normal),
"number": TextStyle(color: Color(0xff005cc5)),
"literal": TextStyle(color: Color(0xff005cc5)),
"variable": TextStyle(color: Color(0xffe36209)),
"template-variable": TextStyle(color: Color(0xffe36209)),
"string": TextStyle(color: Color(0xff032f62)),
"doctag": TextStyle(color: Color(0xff032f62)),
"title": TextStyle(color: Color(0xff6f42c1), fontWeight: FontWeight.bold),
"section": TextStyle(color: Color(0xff6f42c1), fontWeight: FontWeight.bold),
"selector-id": TextStyle(
color: Color(0xff6f42c1),
fontWeight: FontWeight.bold,
),
"type": TextStyle(color: Color(0xff005cc5), fontWeight: FontWeight.bold),
"tag": TextStyle(color: Color(0xff22863a), fontWeight: FontWeight.normal),
"name": TextStyle(color: Color(0xff22863a), fontWeight: FontWeight.normal),
"attribute": TextStyle(
color: Color(0xff22863a),
fontWeight: FontWeight.normal,
),
"regexp": TextStyle(color: Color(0xff032f62)),
"link": TextStyle(color: Color(0xff032f62)),
"symbol": TextStyle(color: Color(0xffe36209)),
"bullet": TextStyle(color: Color(0xffe36209)),
"built_in": TextStyle(color: Color(0xff005cc5)),
"builtin-name": TextStyle(color: Color(0xff005cc5)),
"meta": TextStyle(color: Color(0xff6a737d), fontWeight: FontWeight.bold),
"deletion": TextStyle(backgroundColor: Color(0xffffdddd)),
"addition": TextStyle(backgroundColor: Color(0xffddffdd)),
"emphasis": TextStyle(fontStyle: FontStyle.italic),
"strong": TextStyle(fontWeight: FontWeight.bold),
};
const preHighlightGithubThemeDark = {
"root": TextStyle(
color: Color(0xfff6f8fa),
backgroundColor: Colors.transparent,
),
"comment": TextStyle(color: Color(0xff959da5), fontStyle: FontStyle.italic),
"quote": TextStyle(color: Color(0xff959da5), fontStyle: FontStyle.italic),
"keyword": TextStyle(color: Color(0xffea4a5a), fontWeight: FontWeight.bold),
"selector-tag": TextStyle(
color: Color(0xffea4a5a),
fontWeight: FontWeight.bold,
),
"subst": TextStyle(color: Color(0xfff6f8fa), fontWeight: FontWeight.normal),
"number": TextStyle(color: Color(0xffc8e1ff)),
"literal": TextStyle(color: Color(0xffc8e1ff)),
"variable": TextStyle(color: Color(0xfffb8532)),
"template-variable": TextStyle(color: Color(0xfffb8532)),
"string": TextStyle(color: Color(0xff79b8ff)),
"doctag": TextStyle(color: Color(0xff79b8ff)),
"title": TextStyle(color: Color(0xffb392f0), fontWeight: FontWeight.bold),
"section": TextStyle(color: Color(0xffb392f0), fontWeight: FontWeight.bold),
"selector-id": TextStyle(
color: Color(0xffb392f0),
fontWeight: FontWeight.bold,
),
"type": TextStyle(color: Color(0xff0366d6), fontWeight: FontWeight.bold),
"tag": TextStyle(color: Color(0xff7bcc72), fontWeight: FontWeight.normal),
"name": TextStyle(color: Color(0xff7bcc72), fontWeight: FontWeight.normal),
"attribute": TextStyle(
color: Color(0xff7bcc72),
fontWeight: FontWeight.normal,
),
"regexp": TextStyle(color: Color(0xff79b8ff)),
"link": TextStyle(color: Color(0xff79b8ff)),
"symbol": TextStyle(color: Color(0xfffb8532)),
"bullet": TextStyle(color: Color(0xfffb8532)),
"built_in": TextStyle(color: Color(0xff0366d6)),
"builtin-name": TextStyle(color: Color(0xff0366d6)),
"meta": TextStyle(color: Color(0xff959da5), fontWeight: FontWeight.bold),
"deletion": TextStyle(backgroundColor: Color(0xff67060c)),
"addition": TextStyle(backgroundColor: Color(0xff033a16)),
"emphasis": TextStyle(fontStyle: FontStyle.italic),
"strong": TextStyle(fontWeight: FontWeight.bold),
};
class _MarkdownDetails extends StatefulWidget {
final bool initialOpened;
final String summary;
final List<InlineSpan> contentSpans;
final TextStyle? textStyle;
final double? paragraphHeight;
const _MarkdownDetails({
required this.initialOpened,
required this.summary,
required this.contentSpans,
this.textStyle,
this.paragraphHeight,
});
@override
State<_MarkdownDetails> createState() => _MarkdownDetailsState();
}
class _MarkdownFootnoteModal extends StatefulWidget {
final _MarkdownPayload payload;
const _MarkdownFootnoteModal({required this.payload});
@override
State<_MarkdownFootnoteModal> createState() => _MarkdownFootnoteModalState();
}
class _MarkdownFootnoteModalState extends State<_MarkdownFootnoteModal> {
var urlMode = false;
bool _loadedData = false;
bool urlModeError = false;
late final Uri? urlModeUrl;
OgpData? urlModeData;
Uri? urlModeFavicon;
@override
void initState() {
super.initState();
urlModeUrl = Uri.tryParse(
widget.payload.tree.map((e) => e.textContent).join(" "),
);
urlMode =
urlModeUrl != null && ["http", "https"].contains(urlModeUrl!.scheme);
if (urlMode) {
OgpDataExtract.execute(urlModeUrl!.toString(), userAgent: userAgent)
.then((v) {
_loadedData = true;
if (mounted) setState(() {});
urlModeData = v;
if (mounted) setState(() {});
})
.catchError((_) {
_loadedData = true;
urlModeError = true;
if (mounted) setState(() {});
});
OgpDataExtract.fetchFavicon(urlModeUrl!.toString(), userAgent: userAgent)
.then((v) async {
for (var i in v) {
if (i == null) continue;
final req = await httpClient.head(Uri.parse(i));
if (req.statusCode == 200 &&
[
"image/jpeg",
"image/png",
].contains(req.headers["content-type"])) {
urlModeFavicon = Uri.parse(i);
if (mounted && _loadedData) setState(() {});
break;
}
}
})
.catchError((_) {});
}
}
@override
Widget build(BuildContext context) {
Widget child;
if (urlMode) {
final image = urlModeData?.image;
final title = urlModeData?.title ?? urlModeUrl.toString();
final description = urlModeData?.description;
const duration = kThemeAnimationDuration;
const curve = Curves.fastEaseInToSlowEaseOut;
child = Card.filled(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () async {
await launchUrl(urlModeUrl!, mode: LaunchMode.inAppBrowserView);
if (context.mounted) Navigator.of(context).pop();
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_loadedData || image != null)
Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: duration,
curve: curve,
alignment: Alignment.topCenter,
child: image != null
? Image.network(
image,
loadingBuilder: (_, child, loadingProgress) {
if (loadingProgress == null) return child;
return SizedBox(
width: double.infinity,
child: LinearProgressIndicator(
value:
loadingProgress.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
),
);
},
)
: const SizedBox(
width: double.infinity,
child: LinearProgressIndicator(),
),
),
if (_loadedData) const Divider(height: 1),
],
),
AnimatedSize(
duration: duration,
curve: curve,
child: ListTile(
isThreeLine:
!(!_loadedData || (_loadedData && description == null)),
leading: SizedBox(
height: 24,
width: 24,
child: AnimatedSwitcher(
duration: duration,
child: urlModeFavicon != null
? Image.network(
urlModeFavicon.toString(),
height: 24,
width: 24,
filterQuality: FilterQuality.low,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress != null
? CircularProgressIndicator(
value:
loadingProgress.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
)
: child,
errorBuilder: (_, _, _) =>
const Icon(Icons.public),
)
: const Icon(Icons.public),
),
),
title: AnimatedSize(
duration: duration,
curve: curve,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: curve,
switchOutCurve: curve.flipped,
child: SizedBox(
key: ValueKey(title),
width: double.infinity,
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
),
subtitle: !_loadedData || (_loadedData && description == null)
? null
: AnimatedSize(
duration: duration,
curve: curve,
child: SizedBox(
key: ValueKey(description),
width: double.infinity,
child: Text(
description ?? "",
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
],
),
),
);
} else {
child = Text.rich(
TextSpan(
children: widget.payload.markdown._toInlineSpans(
widget.payload,
block: true,
),
),
);
}
return SingleChildScrollView(
child: SafeArea(
top: false,
left: false,
right: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: child,
),
),
);
}
}
class _MarkdownDetailsState extends State<_MarkdownDetails>
with TickerProviderStateMixin {
bool _expanded = false;
late final AnimationController _animationController;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
value: widget.initialOpened ? 1.0 : 0.0,
duration: kThemeAnimationDuration,
vsync: this,
);
_expanded = widget.initialOpened;
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.fastEaseInToSlowEaseOut,
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleAnimation() {
if (!kIsWeb) selectionHaptic();
setState(() {
_expanded = !_expanded;
_expanded
? _animationController.forward()
: _animationController.reverse();
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: _toggleAnimation,
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Container(
height: widget.paragraphHeight,
width: widget.paragraphHeight,
margin: EdgeInsets.only(
right: widget.paragraphHeight != null
? widget.paragraphHeight! / 3
: 6,
),
child: ExpandIcon(
size: widget.paragraphHeight ?? 16,
onPressed: null,
isExpanded: _expanded,
padding: EdgeInsets.zero,
),
),
Expanded(child: Text(widget.summary)),
],
),
),
),
SizeTransition(
sizeFactor: _animation,
alignment: Alignment.topCenter,
child: Text.rich(
TextSpan(children: widget.contentSpans),
style: widget.textStyle,
),
),
],
);
}
}
class _MarkdownImageScreen extends StatefulWidget {
final String src;
final ImageErrorWidgetBuilder imageErrorBuilder;
final String? title;
const _MarkdownImageScreen({
required this.src,
required this.imageErrorBuilder,
required this.title,
});
@override
State<_MarkdownImageScreen> createState() => _MarkdownImageScreenState();
}
class _MarkdownImageScreenState extends State<_MarkdownImageScreen>
with TickerProviderStateMixin {
final TransformationController _controller = TransformationController();
late final Image _image;
Size? _imageSize;
double _minScale = 1.0;
double _maxScale = 8.0;
@override
void initState() {
super.initState();
_controller.addListener(onUpdate);
_image = Image.network(widget.src);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo image, _) {
_imageSize = Size(
image.image.width.toDouble(),
image.image.height.toDouble(),
);
_minScale = math.min(
1,
_toInverseScale(
1,
_imageSize!,
BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
maxHeight: MediaQuery.of(context).size.height,
),
),
);
_maxScale = math.max(
1,
_toInverseScale(
8,
_imageSize!,
BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
maxHeight: MediaQuery.of(context).size.height,
),
),
);
if (mounted) setState(() {});
}),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void onUpdate() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final finalTitle = widget.title ?? "Untitled Image";
final titleWidget = Text(
finalTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
return Theme(
data: ThemeBuilderData.current!.themeDark(),
child: LayoutBuilder(
builder: (context, constraints) {
final ratio = _ratioFromDouble(_imageSize?.aspectRatio ?? 1);
final realScale = _imageSize != null
? (_toRealScale(
_controller.value.getMaxScaleOnAxis(),
_imageSize!,
constraints,
) *
100)
.round()
: 100;
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.7),
automaticallyImplyLeading: true,
title: widget.title != null
? Hero(
tag: widget.title!,
createRectTween: heroTextCreateRectTween,
flightShuttleBuilder: heroTextFlightShuttleBuilder,
child: titleWidget,
)
: titleWidget,
actions: [
IconButton(
onPressed: () {
launchUrl(Uri.parse(widget.src));
if (!kIsWeb) lightHaptic();
},
icon: const Icon(Icons.open_in_new),
),
],
),
extendBodyBehindAppBar: true,
body: Stack(
children: [
SizedBox.expand(
child: InteractiveViewer(
transformationController: _controller,
minScale: _minScale,
maxScale: _maxScale,
trackpadScrollCausesScale: true,
child: Hero(
tag: widget.src,
child: Image(
image: _image.image,
filterQuality: FilterQuality.high,
isAntiAlias: true,
errorBuilder: widget.imageErrorBuilder,
fit: BoxFit.contain,
),
),
),
),
if (_imageSize != null)
Align(
alignment: Alignment.bottomRight,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
),
),
child: SafeArea(
top: false,
left: false,
right: false,
child: Text.rich(
TextSpan(
children: [
if (realScale.toString().length < 3)
const TextSpan(
text: "0",
style: TextStyle(color: Colors.transparent),
),
TextSpan(
text: "$realScale%",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const TextSpan(text: ""),
TextSpan(
text:
"${_imageSize!.width.round()}×${_imageSize!.height.round()}",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text:
" (${[ratio.numerator, ratio.denominator].join("/")})",
style: const TextStyle(
fontFeatures: [FontFeature.fractions()],
),
),
],
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
],
),
);
},
),
);
}
double _toRealScale(
double scale,
Size imageSize,
BoxConstraints constraints,
) {
final iw = imageSize.width;
final ih = imageSize.height;
final cw = constraints.maxWidth;
final ch = constraints.maxHeight;
if (iw <= 0 || ih <= 0 || cw <= 0 || ch <= 0) return scale;
final fitScale = (iw / ih > cw / ch) ? cw / iw : ch / ih;
return scale * fitScale;
}
double _toInverseScale(
double realScale,
Size imageSize,
BoxConstraints constraints,
) {
final iw = imageSize.width * (constraints.maxWidth / imageSize.width);
final ih = imageSize.height * (constraints.maxHeight / imageSize.height);
final cw = constraints.maxWidth;
final ch = constraints.maxHeight;
if (iw <= 0 || ih <= 0) return realScale;
if (!cw.isFinite || !ch.isFinite) return realScale;
if (cw <= 0 || ch <= 0) return realScale;
// if (iw <= cw && ih <= ch) return realScale;
final fitScale = (iw / ih > cw / ch) ? cw / iw : ch / ih;
return (realScale / fitScale) * (imageSize.width / constraints.maxWidth);
}
({int denominator, int numerator}) _ratioFromDouble(
double v, {
int maxDen = 1000,
bool preferWidth = true,
}) {
if (!v.isFinite || v <= 0) {
throw ArgumentError.value(v, 'v', 'must be > 0 and finite');
}
final inverted = preferWidth && v < 1.0;
if (inverted) v = 1.0 / v;
final a0 = v.floor();
if ((v - a0).abs() < 1e-12) {
return inverted
? (denominator: 1, numerator: a0)
: (numerator: a0, denominator: 1);
}
var p0 = 1;
var q0 = 0;
var p1 = a0;
var q1 = 1;
var frac = v - a0;
while (true) {
if (frac.abs() < 1e-12) break;
final x = 1.0 / frac;
final a = x.floor();
var p2 = a * p1 + p0;
var q2 = a * q1 + q0;
if (q2 > maxDen) {
final k = (maxDen - q0) ~/ q1;
if (k <= 0) {
p2 = p1;
q2 = q1;
} else {
p2 = k * p1 + p0;
q2 = k * q1 + q0;
}
p1 = p2;
q1 = q2;
break;
}
p0 = p1;
q0 = q1;
p1 = p2;
q1 = q2;
frac = x - a;
}
int g(int a, int b) {
a = a.abs();
b = b.abs();
while (b != 0) {
final t = a % b;
a = b;
b = t;
}
return a;
}
var num = p1;
var den = q1;
if (inverted) {
final t = num;
num = den;
den = t;
}
final gg = g(num, den);
return (numerator: num ~/ gg, denominator: den ~/ gg);
}
}
class TapLongPressGestureRecognizer extends GestureRecognizer {
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final TapGestureRecognizer _tap = TapGestureRecognizer();
final LongPressGestureRecognizer _longPress = LongPressGestureRecognizer();
TapLongPressGestureRecognizer({this.onTap, this.onLongPress}) {
_tap.onTap = onTap;
_longPress.onLongPress = onLongPress;
}
@override
void addPointer(PointerDownEvent event) {
_tap.addPointer(event);
_longPress.addPointer(event);
}
@override
void acceptGesture(int pointer) {
_tap.acceptGesture(pointer);
_longPress.acceptGesture(pointer);
}
@override
void rejectGesture(int pointer) {
_tap.rejectGesture(pointer);
_longPress.rejectGesture(pointer);
}
@override
void dispose() {
_tap.dispose();
_longPress.dispose();
super.dispose();
}
@override
String get debugDescription => "TapLongPressGestureRecognizer";
}
extension on md.Node {
String toInfoString() {
switch (this) {
case final md.Text e:
return 'Text("${e.text}")';
case final md.Element e:
return 'Element(tag: ${e.tag}, attributes: ${e.attributes}, children: [${e.children?.map((e) => e.toInfoString()).join(", ")}])';
default:
return 'Node()';
}
}
}
// extension on InlineSpan {
// String toInfoString() {
// switch (this) {
// case TextSpan e:
// return 'TextSpan(text: ${e.text}, style: ${e.style}, children: [${e.children?.map((e) => e.toInfoString()).join(", ")}])';
// case WidgetSpan e:
// return 'WidgetSpan(widget: ${e.child}, style: ${e.style})';
// default:
// return 'InlineSpan()';
// }
// }
// }