Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions packages/flutter_markdown/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,84 @@ abstract class MarkdownBuilderDelegate {
TextSpan formatText(MarkdownStyleSheet styleSheet, String code);
}

/// An anchor in markdown is basically a marker inside the markdown text
/// with an id.
/// We can use this to scroll to a specific place inside the text.
///
/// When using [md.ExtensionSet.gitHubWeb] (more specifically
/// [md.HeaderWithIdSyntax] and [md.SetextHeaderWithIdSyntax]) anchors are
/// automatically created for headings.
///
/// We can then use the [AnchorController] to scroll to a specific header and
/// see which headers are currently visible.
///
/// Usually one can also create anchors in markdown via the html tag `a` but as
/// we can't render HTML inside flutter currently (as far as I know) only
/// headers should have an anchor.
class AnchorData {
AnchorData(this.id, this.text);

/// The id of the anchor e.g. 'table-of-contents'.
final String id;

/// The name of the anchor (so the text of the heading if the anchor is for a
/// heading).
/// E.g. 'Table of contents'.
final String text;

@override
String toString() => 'AnchorData(id: $id, text: $text)';
}

/// [AnchorData] with an index.
///
/// The index is the position inside `children` passed by
/// [MarkdownWidget.build].
/// We need it to scroll to a specific anchor via [ScrollablePositionedList].
class IndexedAnchorData extends AnchorData {
IndexedAnchorData(this.index, String id, String text) : super(id, text);

final int index;

@override
String toString() {
return 'IndexedAnchor(id: $id, text: $text, index: $index)';
}
}

/// [Anchor] is used to wrap around a [child] (most likely a markdown heading)
/// while building the markdown widgets.
///
/// After all markdown widgets are built we search these widgets for all
/// [Anchor] so we can know what index they will have when rendered inside a
/// list in flutter.
///
/// The index is needed to scroll via [ScrollablePositionedList] (which renders
/// the markdown text) to a specific anchor.
/// [ScrollablePositionedList] requires an index to scroll to a specific widget.
///
/// For general infos regarding anchors see [AnchorData].
class Anchor extends StatelessWidget {
const Anchor({
required this.child,
required this.data,
Key? key,
}) : super(key: key);

final Widget child;
final AnchorData data;

@override
Widget build(BuildContext context) {
return child;
}

@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'Anchor(data: $data)';
}
}

/// Builds a [Widget] tree from parsed Markdown.
///
/// See also:
Expand Down Expand Up @@ -169,6 +247,18 @@ class MarkdownBuilder implements md.NodeVisitor {
final List<_TableElement> _tables = <_TableElement>[];
final List<_InlineElement> _inlines = <_InlineElement>[];
final List<GestureRecognizer> _linkHandlers = <GestureRecognizer>[];
List<IndexedAnchorData> _anchorsWithIndex = <IndexedAnchorData>[];

int? getIndexForAnchor(String anchorId) {
final Iterable<IndexedAnchorData> anchors =
_anchorsWithIndex.where((anchor) => anchor.id == anchorId);
return anchors.isEmpty ? null : anchors.first.index;
}

List<IndexedAnchorData> getIndexedAnchors() {
return _anchorsWithIndex;
}

final ScrollController _preScrollController = ScrollController();
String? _currentBlockTag;
String? _lastVisitedTag;
Expand All @@ -192,12 +282,83 @@ class MarkdownBuilder implements md.NodeVisitor {
node.accept(this);
}

// We loop through all widgets to filter out [Anchor] and return
// [IndexedAnchorData] so we know which widget index we need to scroll to
// to get to a certain anchor inside the rendered markdown text.
//
// We need the index for scrolling between items because we use
// [ScrollablePositionedList] (which requires an index for scrolling to a
// widget inside the list).
final List<Widget> widgets = _flattenWidgets(_blocks.single.children);
_anchorsWithIndex = _getAnchorsWithIndex(widgets);

assert(_tables.isEmpty);
assert(_inlines.isEmpty);
assert(!_isInBlockquote);
return _blocks.single.children;
}

List<IndexedAnchorData> _getAnchorsWithIndex(List<Widget> widgets) {
final List<IndexedAnchorData> _anchors = <IndexedAnchorData>[];

for (int i = 0; i < widgets.length; i++) {
final Widget widget = widgets[i];
if (widget is Anchor) {
// If we have already an index for this anchor, i.e. multiple anchors
// with the same id (which *should* never happen) then we use the first
// occasion of the anchor.
if (getIndexForAnchor(widget.data.id) != null) {
continue;
}
_anchors.add(IndexedAnchorData(i, widget.data.id, widget.data.text));
}
}

return _anchors;
}

/// For all [widgets] we get their children and the children of these children
/// (etc) and return them all in a "flattened" list.
/// In this way we basically squish a whole widget "tree" into one layer so we
/// can later search for a specific widget ([Anchor]).
List<Widget> _flattenWidgets(List<Widget> widgets) {
final List<Widget> _widgets = <Widget>[];

_widgets.addAll(widgets);
for (final Widget widget in widgets) {
final List<Widget>? children = _tryGetChildrenCombined(widget);
if (children != null) {
_widgets.addAll(_flattenWidgets(children));
}
}
return _widgets;
}

List<Widget>? _tryGetChildrenCombined(dynamic widget) {
final Widget? child = _tryGetChild(widget);
final List<Widget>? children = _tryGetChildren(widget);
if (child != null) {
return <Widget>[child];
}
if (children != null) {
return children;
}
}

// I'm sorry for these hacks
List<Widget>? _tryGetChildren(dynamic widget) {
try {
return widget.children as List<Widget>;
} catch (_) {}
}

// I'm sorry for these hacks
Widget? _tryGetChild(dynamic widget) {
try {
return widget.child as Widget;
} catch (_) {}
}

@override
bool visitElementBefore(md.Element element) {
final String tag = element.tag;
Expand Down Expand Up @@ -385,6 +546,15 @@ class MarkdownBuilder implements md.NodeVisitor {
child = const SizedBox();
}

/// So we can later find all anchors inside the returned widgets by
/// looking for all instances of [Anchor].
if (element.generatedId != null) {
child = Anchor(
child: child,
data: AnchorData(element.generatedId!, element.textContent),
);
}

if (_isListTag(tag)) {
assert(_listIndents.isNotEmpty);
_listIndents.removeLast();
Expand Down
Loading