From 181effad2350a6e14dda8b0ccd3ae0b4a3d3f471 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Wed, 6 Apr 2022 21:56:06 +0200 Subject: [PATCH 01/10] WIP: Add Anchor widget to headings to later use in RelativeAnchorsMarkdown. --- .../flutter_markdown/lib/src/builder.dart | 65 +++++++++++ packages/flutter_markdown/lib/src/widget.dart | 110 ++++++++++++++++++ packages/flutter_markdown/pubspec.yaml | 9 +- packages/flutter_markdown/test/all.dart | 2 + .../test/relative_anchors_test.dart | 106 +++++++++++++++++ 5 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 packages/flutter_markdown/test/relative_anchors_test.dart diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 0f6b20ea8f3..8359c95cfa2 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -85,6 +85,38 @@ abstract class MarkdownBuilderDelegate { TextSpan formatText(MarkdownStyleSheet styleSheet, String code); } +class IndexedAnchor { + IndexedAnchor(this.anchorId, this.index); + + final int index; + final String anchorId; +} + +class Anchor extends StatelessWidget { + const Anchor({required this.child, required this.anchorId, Key? key}) + : super(key: key); + + final Widget child; + final String anchorId; + + @override + Widget build(BuildContext context) { + return child; + } +} + +extension on md.Element { + String toReadableString() { + return 'md.Element(textContent: $textContent, tag: $tag, children: $children, attributes: $attributes, generatedId: $generatedId)'; + } +} + +extension on md.Text { + String toReadableString() { + return 'md.Text(text: $text)'; + } +} + /// Builds a [Widget] tree from parsed Markdown. /// /// See also: @@ -162,6 +194,10 @@ class MarkdownBuilder implements md.NodeVisitor { final List<_TableElement> _tables = <_TableElement>[]; final List<_InlineElement> _inlines = <_InlineElement>[]; final List _linkHandlers = []; + final Map _anchorsWithIndex = {}; + + int? getIndexForAnchor(String anchorId) => _anchorsWithIndex[anchorId]; + String? _currentBlockTag; String? _lastVisitedTag; bool _isInBlockquote = false; @@ -184,6 +220,19 @@ class MarkdownBuilder implements md.NodeVisitor { node.accept(this); } + for (int i = 0; i < _blocks.single.children.length; i++) { + final Widget widget = _blocks.single.children[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 anchor. + if (_anchorsWithIndex[widget.anchorId] != null) { + continue; + } + _anchorsWithIndex[widget.anchorId] = i; + } + } + assert(_tables.isEmpty); assert(_inlines.isEmpty); assert(!_isInBlockquote); @@ -192,6 +241,7 @@ class MarkdownBuilder implements md.NodeVisitor { @override bool visitElementBefore(md.Element element) { + print('visitElementBefore: ${element.toReadableString()}'); final String tag = element.tag; _currentBlockTag ??= tag; _lastVisitedTag = tag; @@ -286,6 +336,7 @@ class MarkdownBuilder implements md.NodeVisitor { @override void visitText(md.Text text) { + print('visitText: md.${text.toReadableString()}'); // Don't allow text directly under the root. if (_blocks.last.tag == null) { return; @@ -334,6 +385,9 @@ class MarkdownBuilder implements md.NodeVisitor { ), ); } else { + // Headers: H1-H6 are built here + // # first **header** will cause this to be executed for + // "first" and "header" seperately child = _buildRichText( TextSpan( style: _isInBlockquote @@ -354,6 +408,8 @@ class MarkdownBuilder implements md.NodeVisitor { @override void visitElementAfter(md.Element element) { + print('visitElementAfter: ${element.toReadableString()}'); + final String tag = element.tag; if (_isBlockTag(tag)) { @@ -373,6 +429,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, + anchorId: element.generatedId!, + ); + } + if (_isListTag(tag)) { assert(_listIndents.isNotEmpty); _listIndents.removeLast(); diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 0e4ca00854f..6dfea29fdb1 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '_functions_io.dart' if (dart.library.html) '_functions_web.dart'; @@ -265,6 +266,13 @@ abstract class MarkdownWidget extends StatefulWidget { /// specification on soft line breaks when lines of text are joined. final bool softLineBreak; + late int? Function(String anchorId) _getIndexForAnchor; + + /// Index of anchor widget for children passed in [build]. + /// Used so that [ItemScrollController] can scroll to an anchor inside + /// [RelativeAnchorsMarkdown] + int? getIndexForAnchor(String anchorId) => _getIndexForAnchor(anchorId); + /// Subclasses should override this function to display the given children, /// which are the parsed representation of [data]. @protected @@ -338,6 +346,8 @@ class _MarkdownWidgetState extends State softLineBreak: widget.softLineBreak, ); + widget._getIndexForAnchor = builder.getIndexForAnchor; + _children = builder.build(astNodes); } @@ -453,6 +463,106 @@ class MarkdownBody extends MarkdownWidget { } } +/// A scrolling widget that parses and displays Markdown. +/// +/// Supports all GitHub Flavored Markdown from the +/// [specification](https://github.github.com/gfm/). +/// +/// See also: +/// +/// * [MarkdownBody], which is a non-scrolling container of Markdown. +/// * +class RelativeAnchorsMarkdown extends MarkdownWidget { + /// Creates a scrolling widget that parses and displays Markdown. + const RelativeAnchorsMarkdown({ + Key? key, + required String data, + bool selectable = false, + MarkdownStyleSheet? styleSheet, + MarkdownStyleSheetBaseTheme? styleSheetTheme, + SyntaxHighlighter? syntaxHighlighter, + MarkdownTapLinkCallback? onTapLink, + VoidCallback? onTapText, + String? imageDirectory, + List? blockSyntaxes, + List? inlineSyntaxes, + md.ExtensionSet? extensionSet, + MarkdownImageBuilder? imageBuilder, + MarkdownCheckboxBuilder? checkboxBuilder, + MarkdownBulletBuilder? bulletBuilder, + Map builders = + const {}, + Map paddingBuilders = + const {}, + MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = + MarkdownListItemCrossAxisAlignment.baseline, + this.padding = const EdgeInsets.all(16.0), + this.itemPositionsListener, + this.itemScrollController, + this.physics, + this.shrinkWrap = false, + bool softLineBreak = false, + }) : super( + key: key, + data: data, + selectable: selectable, + styleSheet: styleSheet, + styleSheetTheme: styleSheetTheme, + syntaxHighlighter: syntaxHighlighter, + onTapLink: onTapLink, + onTapText: onTapText, + imageDirectory: imageDirectory, + blockSyntaxes: blockSyntaxes, + inlineSyntaxes: inlineSyntaxes, + extensionSet: extensionSet, + imageBuilder: imageBuilder, + checkboxBuilder: checkboxBuilder, + builders: builders, + paddingBuilders: paddingBuilders, + listItemCrossAxisAlignment: listItemCrossAxisAlignment, + bulletBuilder: bulletBuilder, + softLineBreak: softLineBreak, + ); + + /// The amount of space by which to inset the children. + final EdgeInsets padding; + + /// Provides a listenable iterable of [itemPositions] of items that are on + /// screen and their locations. + final ItemPositionsListener? itemPositionsListener; + + /// An object that can be used to jump or scroll to a particular position in + /// the [RelativeAnchorsMarkdown] widget. + /// + /// See also: [ScrollablePositionedList.itemScrollController] + final ItemScrollController? itemScrollController; + + /// How the scroll view should respond to user input. + /// + /// See also: [ScrollView.physics] + final ScrollPhysics? physics; + + /// Whether the extent of the scroll view in the scroll direction should be + /// determined by the contents being viewed. + /// + /// See also: [ScrollView.shrinkWrap] + final bool shrinkWrap; + + @override + Widget build(BuildContext context, List? children) { + children!; + return ScrollablePositionedList.builder( + padding: padding, + itemCount: children.length, + itemBuilder: (BuildContext context, int index) => children[index], + physics: physics, + shrinkWrap: shrinkWrap, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); + } +} + /// A scrolling widget that parses and displays Markdown. /// /// Supports all GitHub Flavored Markdown from the diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml index ae0a7e49dd3..10fa09ff6f2 100644 --- a/packages/flutter_markdown/pubspec.yaml +++ b/packages/flutter_markdown/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_markdown -description: A Markdown renderer for Flutter. Create rich text output, - including text styles, tables, links, and more, from plain text data - formatted with simple Markdown tags. +description: A Markdown renderer for Flutter. Create rich text output, including + text styles, tables, links, and more, from plain text data formatted with + simple Markdown tags. repository: https://github.com/flutter/packages/tree/main/packages/flutter_markdown issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_markdown%22 version: 0.6.9+1 @@ -13,9 +13,10 @@ environment: dependencies: flutter: sdk: flutter - markdown: ^4.0.0 + markdown: ^5.0.0 meta: ^1.3.0 path: ^1.8.0 + scrollable_positioned_list: ^0.2.3 dev_dependencies: flutter_test: diff --git a/packages/flutter_markdown/test/all.dart b/packages/flutter_markdown/test/all.dart index b348f8e23ec..cbfe4c7f425 100644 --- a/packages/flutter_markdown/test/all.dart +++ b/packages/flutter_markdown/test/all.dart @@ -14,6 +14,7 @@ import 'image_test.dart' as image_test; import 'line_break_test.dart' as line_break_test; import 'link_test.dart' as link_test; import 'list_test.dart' as list_test; +import 'relative_anchors_test.dart' as relative_anchors; import 'scrollable_test.dart' as scrollable_test; import 'style_sheet_test.dart' as style_sheet_test; import 'table_test.dart' as table_test; @@ -33,6 +34,7 @@ void main() { line_break_test.defineTests(); link_test.defineTests(); list_test.defineTests(); + relative_anchors.defineTests(); scrollable_test.defineTests(); style_sheet_test.defineTests(); table_test.defineTests(); diff --git a/packages/flutter_markdown/test/relative_anchors_test.dart b/packages/flutter_markdown/test/relative_anchors_test.dart new file mode 100644 index 00000000000..0741feee903 --- /dev/null +++ b/packages/flutter_markdown/test/relative_anchors_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + testWidgets('test', (tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + extensionSet: ExtensionSet.gitHubFlavored, + data: ''' +Hello we link to the foo chapter heading [here](#foo-chapter) +Hello we link to the foo chapter text foobar [here](#foobar) + +We can also link to some a tag. + +### Foo chapter + + Hey this the foobar text that has an "a" tag before. +''', +// Doesn't work: +// Hey this the foobar text that has an "a" tag before. +// Hey this the foobar text that has an "a" tag before. +// Weil flutter kein HTML wird das einfach drinnen gelassen. + ), + ), + ); + + // expect(find.textContaining('Hello.'), findsOneWidget); + + for (final Widget widget in tester.allWidgets) { + if (widget is RichText) { + final TextSpan span = widget.text as TextSpan; + final String text = _extractTextFromTextSpan(span); + print(text); + } + } + }); + testWidgets('debugging', (tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + extensionSet: ExtensionSet.gitHubWeb, + data: ''' +link to [heading below](#first-header-with-code) +# first **header** with `code` +''', +// data: ''' +// # first **header** with `code` +// ## second header +// ### third header +// #### fourth header +// ##### fifth header +// ###### sixth header +// ''', + ), + ), + ); + + // expect(find.textContaining('Hello.'), findsOneWidget); + + for (final Widget widget in tester.allWidgets) { + if (widget is RichText) { + final TextSpan span = widget.text as TextSpan; + final String text = _extractTextFromTextSpan(span); + print(text); + } + } + }); + + test('markdown', () { + final res = markdownToHtml( + ''' +Hello we link to the foo chapter heading [here](#foo-chapter) +Hello we link to the foo chapter text foobar [here](#foobar) + +test +--- + +We can also link to some a tag. + +### Foo chapter + + Hey this the foobar text that has an "a" tag before. +''', + extensionSet: ExtensionSet.gitHubWeb, + ); + + print(res); + }); +} + +String _extractTextFromTextSpan(TextSpan span) { + String text = span.text ?? ''; + if (span.children != null) { + for (final TextSpan child in span.children! as Iterable) { + text += _extractTextFromTextSpan(child); + } + } + return text; +} From 8afbbf8d2e32b2fbaa94abf80d4971dcec63624e Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Mon, 11 Apr 2022 19:43:01 +0200 Subject: [PATCH 02/10] First working version - with crimes against programmers though. --- .../flutter_markdown/lib/src/builder.dart | 62 +++++++++++-- packages/flutter_markdown/lib/src/widget.dart | 87 ++++++++++++++++++- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 8359c95cfa2..6eb6c46a0dc 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -90,6 +90,10 @@ class IndexedAnchor { final int index; final String anchorId; + + String toString() { + return 'IndexedAnchor(anchorId: $anchorId, index: $index)'; + } } class Anchor extends StatelessWidget { @@ -103,6 +107,11 @@ class Anchor extends StatelessWidget { Widget build(BuildContext context) { return child; } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'Anchor(anchorId: $anchorId)'; + } } extension on md.Element { @@ -197,6 +206,11 @@ class MarkdownBuilder implements md.NodeVisitor { final Map _anchorsWithIndex = {}; int? getIndexForAnchor(String anchorId) => _anchorsWithIndex[anchorId]; + List getIndexedAnchors() { + return _anchorsWithIndex.entries + .map((e) => IndexedAnchor(e.key, e.value)) + .toList(); + } String? _currentBlockTag; String? _lastVisitedTag; @@ -220,9 +234,12 @@ class MarkdownBuilder implements md.NodeVisitor { node.accept(this); } - for (int i = 0; i < _blocks.single.children.length; i++) { - final Widget widget = _blocks.single.children[i]; + final widgets = _flattenWidgets(_blocks.single.children); + + for (int i = 0; i < widgets.length; i++) { + final Widget widget = widgets[i]; if (widget is Anchor) { + print('Got anchor: $widget'); // 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 anchor. @@ -239,9 +256,45 @@ class MarkdownBuilder implements md.NodeVisitor { return _blocks.single.children; } + List _flattenWidgets(List widgets) { + List _widgets = []; + + _widgets.addAll(widgets); + for (Widget widget in widgets) { + print(widget.runtimeType); + final children = _tryGetChildrenCombined(widget); + if (children != null) { + _widgets.addAll(_flattenWidgets(children)); + } + } + return _widgets; + } + + List? _tryGetChildrenCombined(dynamic widget) { + final child = _tryGetChild(widget); + final children = _tryGetChildren(widget); + if (child != null) { + return [child]; + } + if (children != null) { + return children; + } + } + + List? _tryGetChildren(dynamic widget) { + try { + return widget.children as List; + } catch (e) {} + } + + Widget? _tryGetChild(dynamic widget) { + try { + return widget.child as Widget; + } catch (_) {} + } + @override bool visitElementBefore(md.Element element) { - print('visitElementBefore: ${element.toReadableString()}'); final String tag = element.tag; _currentBlockTag ??= tag; _lastVisitedTag = tag; @@ -336,7 +389,6 @@ class MarkdownBuilder implements md.NodeVisitor { @override void visitText(md.Text text) { - print('visitText: md.${text.toReadableString()}'); // Don't allow text directly under the root. if (_blocks.last.tag == null) { return; @@ -408,8 +460,6 @@ class MarkdownBuilder implements md.NodeVisitor { @override void visitElementAfter(md.Element element) { - print('visitElementAfter: ${element.toReadableString()}'); - final String tag = element.tag; if (_isBlockTag(tag)) { diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 6dfea29fdb1..e5ddbf214d9 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -266,13 +267,13 @@ abstract class MarkdownWidget extends StatefulWidget { /// specification on soft line breaks when lines of text are joined. final bool softLineBreak; - late int? Function(String anchorId) _getIndexForAnchor; - /// Index of anchor widget for children passed in [build]. /// Used so that [ItemScrollController] can scroll to an anchor inside /// [RelativeAnchorsMarkdown] int? getIndexForAnchor(String anchorId) => _getIndexForAnchor(anchorId); + List getIndexedAnchors() => _indexedAnchors; + /// Subclasses should override this function to display the given children, /// which are the parsed representation of [data]. @protected @@ -282,6 +283,10 @@ abstract class MarkdownWidget extends StatefulWidget { _MarkdownWidgetState createState() => _MarkdownWidgetState(); } +// TODO: Horrible hack, should be only temporary. +late int? Function(String anchorId) _getIndexForAnchor; +late List _indexedAnchors; + class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { List? _children; @@ -346,7 +351,8 @@ class _MarkdownWidgetState extends State softLineBreak: widget.softLineBreak, ); - widget._getIndexForAnchor = builder.getIndexForAnchor; + _getIndexForAnchor = builder.getIndexForAnchor; + _indexedAnchors = builder.getIndexedAnchors(); _children = builder.build(astNodes); } @@ -463,6 +469,73 @@ class MarkdownBody extends MarkdownWidget { } } +class AnchorsController { + ItemPositionsListener? _itemPositionsListener; + ItemScrollController? _itemScrollController; + int? Function(String anchorId)? _getIndexOfAnchor; + List? _indexedAnchors; + + ValueNotifier> _anchorPositions = + ValueNotifier>(const []); + + void replaceItemPositionsListener( + ItemPositionsListener itemPositionsListener) { + _itemPositionsListener = itemPositionsListener; + // We should also remove the previous listener closure of the old ItemPositionsListener + _itemPositionsListener!.itemPositions.addListener(() { + _anchorPositions.value = + filterAnchorPositions(itemPositionsListener.itemPositions.value); + }); + } + + Iterable filterAnchorPositions( + Iterable itemPositions) { + List _anchorPositions = []; + for (final itemPosition in itemPositions) { + final anchorsWithIndex = _indexedAnchors! + .where((indexedAnchor) => itemPosition.index == indexedAnchor.index); + if (anchorsWithIndex.isEmpty) { + continue; + } + final anchorWithIndex = anchorsWithIndex.first; + _anchorPositions.add(AnchorPosition( + anchorId: anchorWithIndex.anchorId, + itemLeadingEdge: itemPosition.itemLeadingEdge, + itemTrailingEdge: itemPosition.itemTrailingEdge)); + } + return _anchorPositions; + } + + Future scrollToAnchor(String anchorId) { + print('anchors: $_indexedAnchors'); + final int? index = _getIndexOfAnchor!(anchorId); + if (index == null) { + throw ArgumentError('Unknown anchorId'); + } + return _itemScrollController!.scrollTo( + index: index, + duration: const Duration(milliseconds: 100), + ); + } + + /// The position of anchors that are at least partially visible in the viewport. + ValueListenable> get anchorPositions { + return _anchorPositions; + } +} + +class AnchorPosition { + AnchorPosition({ + required this.anchorId, + required this.itemLeadingEdge, + required this.itemTrailingEdge, + }); + + final String anchorId; + final double itemLeadingEdge; + final double itemTrailingEdge; +} + /// A scrolling widget that parses and displays Markdown. /// /// Supports all GitHub Flavored Markdown from the @@ -499,6 +572,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { this.padding = const EdgeInsets.all(16.0), this.itemPositionsListener, this.itemScrollController, + required this.anchorsController, this.physics, this.shrinkWrap = false, bool softLineBreak = false, @@ -537,6 +611,8 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { /// See also: [ScrollablePositionedList.itemScrollController] final ItemScrollController? itemScrollController; + final AnchorsController anchorsController; + /// How the scroll view should respond to user input. /// /// See also: [ScrollView.physics] @@ -550,6 +626,11 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { @override Widget build(BuildContext context, List? children) { + anchorsController._getIndexOfAnchor = getIndexForAnchor; + anchorsController._itemPositionsListener = itemPositionsListener!; + anchorsController._itemScrollController = itemScrollController!; + anchorsController._indexedAnchors = getIndexedAnchors(); + children!; return ScrollablePositionedList.builder( padding: padding, From cab00134de3426bff80ee9e1aae59e31e65a2e29 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Tue, 12 Apr 2022 22:28:52 +0200 Subject: [PATCH 03/10] More horrible hacks to get AnchorPositions working --- .../flutter_markdown/lib/src/builder.dart | 96 +++++++++++++------ packages/flutter_markdown/lib/src/widget.dart | 68 +++++++------ 2 files changed, 105 insertions(+), 59 deletions(-) diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 6eb6c46a0dc..53df1b47399 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -85,23 +85,36 @@ abstract class MarkdownBuilderDelegate { TextSpan formatText(MarkdownStyleSheet styleSheet, String code); } -class IndexedAnchor { - IndexedAnchor(this.anchorId, this.index); +class IndexedAnchorData extends AnchorData { + IndexedAnchorData(this.index, String id, String text) : super(id, text); final int index; - final String anchorId; + @override String toString() { - return 'IndexedAnchor(anchorId: $anchorId, index: $index)'; + return 'IndexedAnchor(id: $id, text: $text, index: $index)'; } } +class AnchorData { + AnchorData(this.id, this.text); + + final String id; + final String text; + + @override + String toString() => 'AnchorData(id: $id, text: $text)'; +} + class Anchor extends StatelessWidget { - const Anchor({required this.child, required this.anchorId, Key? key}) - : super(key: key); + const Anchor({ + required this.child, + required this.data, + Key? key, + }) : super(key: key); final Widget child; - final String anchorId; + final AnchorData data; @override Widget build(BuildContext context) { @@ -110,7 +123,7 @@ class Anchor extends StatelessWidget { @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'Anchor(anchorId: $anchorId)'; + return 'Anchor(data: $data)'; } } @@ -203,13 +216,16 @@ class MarkdownBuilder implements md.NodeVisitor { final List<_TableElement> _tables = <_TableElement>[]; final List<_InlineElement> _inlines = <_InlineElement>[]; final List _linkHandlers = []; - final Map _anchorsWithIndex = {}; + List _anchorsWithIndex = []; + + int? getIndexForAnchor(String anchorId) { + final Iterable anchors = + _anchorsWithIndex.where((anchor) => anchor.id == anchorId); + return anchors.isEmpty ? null : anchors.first.index; + } - int? getIndexForAnchor(String anchorId) => _anchorsWithIndex[anchorId]; - List getIndexedAnchors() { - return _anchorsWithIndex.entries - .map((e) => IndexedAnchor(e.key, e.value)) - .toList(); + List getIndexedAnchors() { + return _anchorsWithIndex; } String? _currentBlockTag; @@ -234,35 +250,51 @@ class MarkdownBuilder implements md.NodeVisitor { node.accept(this); } - final widgets = _flattenWidgets(_blocks.single.children); + // 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 widgets = _flattenWidgets(_blocks.single.children); + _anchorsWithIndex = _getAnchorsWithIndex(widgets); + + assert(_tables.isEmpty); + assert(_inlines.isEmpty); + assert(!_isInBlockquote); + return _blocks.single.children; + } + + List _getAnchorsWithIndex(List widgets) { + final List _anchors = []; for (int i = 0; i < widgets.length; i++) { final Widget widget = widgets[i]; if (widget is Anchor) { - print('Got anchor: $widget'); // 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 anchor. - if (_anchorsWithIndex[widget.anchorId] != null) { + if (getIndexForAnchor(widget.data.id) != null) { continue; } - _anchorsWithIndex[widget.anchorId] = i; + _anchors.add(IndexedAnchorData(i, widget.data.id, widget.data.text)); } } - assert(_tables.isEmpty); - assert(_inlines.isEmpty); - assert(!_isInBlockquote); - return _blocks.single.children; + 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 _flattenWidgets(List widgets) { - List _widgets = []; + final List _widgets = []; _widgets.addAll(widgets); - for (Widget widget in widgets) { - print(widget.runtimeType); - final children = _tryGetChildrenCombined(widget); + for (final Widget widget in widgets) { + final List? children = _tryGetChildrenCombined(widget); if (children != null) { _widgets.addAll(_flattenWidgets(children)); } @@ -271,22 +303,24 @@ class MarkdownBuilder implements md.NodeVisitor { } List? _tryGetChildrenCombined(dynamic widget) { - final child = _tryGetChild(widget); - final children = _tryGetChildren(widget); + final Widget? child = _tryGetChild(widget); + final List? children = _tryGetChildren(widget); if (child != null) { - return [child]; + return [child]; } if (children != null) { return children; } } + // I'm sorry for these hacks List? _tryGetChildren(dynamic widget) { try { return widget.children as List; - } catch (e) {} + } catch (_) {} } + // I'm sorry for these hacks Widget? _tryGetChild(dynamic widget) { try { return widget.child as Widget; @@ -484,7 +518,7 @@ class MarkdownBuilder implements md.NodeVisitor { if (element.generatedId != null) { child = Anchor( child: child, - anchorId: element.generatedId!, + data: AnchorData(element.generatedId!, element.textContent), ); } diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index e5ddbf214d9..0f32d574f2f 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -272,7 +272,7 @@ abstract class MarkdownWidget extends StatefulWidget { /// [RelativeAnchorsMarkdown] int? getIndexForAnchor(String anchorId) => _getIndexForAnchor(anchorId); - List getIndexedAnchors() => _indexedAnchors; + List getIndexedAnchors() => _getIndexedAnchors(); /// Subclasses should override this function to display the given children, /// which are the parsed representation of [data]. @@ -285,7 +285,7 @@ abstract class MarkdownWidget extends StatefulWidget { // TODO: Horrible hack, should be only temporary. late int? Function(String anchorId) _getIndexForAnchor; -late List _indexedAnchors; +late List Function() _getIndexedAnchors; class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { @@ -352,7 +352,7 @@ class _MarkdownWidgetState extends State ); _getIndexForAnchor = builder.getIndexForAnchor; - _indexedAnchors = builder.getIndexedAnchors(); + _getIndexedAnchors = builder.getIndexedAnchors; _children = builder.build(astNodes); } @@ -473,67 +473,79 @@ class AnchorsController { ItemPositionsListener? _itemPositionsListener; ItemScrollController? _itemScrollController; int? Function(String anchorId)? _getIndexOfAnchor; - List? _indexedAnchors; + List Function()? _getIndexedAnchors; + List? _indexedAnchors; ValueNotifier> _anchorPositions = ValueNotifier>(const []); - void replaceItemPositionsListener( + List getIndexedAnchors() { + return _indexedAnchors ?? []; + } + + Future scrollToAnchor(String anchorId) { + final int? index = _getIndexOfAnchor!(anchorId); + if (index == null) { + throw ArgumentError('Unknown anchorId'); + } + return _itemScrollController!.scrollTo( + index: index, + duration: const Duration(milliseconds: 100), + ); + } + + /// The position of anchors that are at least partially visible in the viewport. + ValueListenable> get anchorPositions { + return _anchorPositions; + } + + void _replaceItemPositionsListener( ItemPositionsListener itemPositionsListener) { _itemPositionsListener = itemPositionsListener; // We should also remove the previous listener closure of the old ItemPositionsListener _itemPositionsListener!.itemPositions.addListener(() { + _indexedAnchors = _getIndexedAnchors!(); _anchorPositions.value = - filterAnchorPositions(itemPositionsListener.itemPositions.value); + _filterAnchorPositions(itemPositionsListener.itemPositions.value); }); } - Iterable filterAnchorPositions( + Iterable _filterAnchorPositions( Iterable itemPositions) { List _anchorPositions = []; + for (final itemPosition in itemPositions) { final anchorsWithIndex = _indexedAnchors! .where((indexedAnchor) => itemPosition.index == indexedAnchor.index); + if (anchorsWithIndex.isEmpty) { continue; } final anchorWithIndex = anchorsWithIndex.first; _anchorPositions.add(AnchorPosition( - anchorId: anchorWithIndex.anchorId, + anchor: anchorWithIndex, itemLeadingEdge: itemPosition.itemLeadingEdge, itemTrailingEdge: itemPosition.itemTrailingEdge)); } - return _anchorPositions; - } - - Future scrollToAnchor(String anchorId) { - print('anchors: $_indexedAnchors'); - final int? index = _getIndexOfAnchor!(anchorId); - if (index == null) { - throw ArgumentError('Unknown anchorId'); - } - return _itemScrollController!.scrollTo( - index: index, - duration: const Duration(milliseconds: 100), - ); - } - /// The position of anchors that are at least partially visible in the viewport. - ValueListenable> get anchorPositions { return _anchorPositions; } } class AnchorPosition { AnchorPosition({ - required this.anchorId, + required this.anchor, required this.itemLeadingEdge, required this.itemTrailingEdge, }); - final String anchorId; + final AnchorData anchor; final double itemLeadingEdge; final double itemTrailingEdge; + + @override + String toString() => + 'AnchorPosition(anchor: $anchor, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; } /// A scrolling widget that parses and displays Markdown. @@ -627,9 +639,9 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { @override Widget build(BuildContext context, List? children) { anchorsController._getIndexOfAnchor = getIndexForAnchor; - anchorsController._itemPositionsListener = itemPositionsListener!; + anchorsController._replaceItemPositionsListener(itemPositionsListener!); anchorsController._itemScrollController = itemScrollController!; - anchorsController._indexedAnchors = getIndexedAnchors(); + anchorsController._getIndexedAnchors = getIndexedAnchors; children!; return ScrollablePositionedList.builder( From 8532cd44acb389bc952cc378ffd0d83e9929d1d3 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Tue, 12 Apr 2022 23:07:08 +0200 Subject: [PATCH 04/10] Clean up a little and add documentation. --- .../flutter_markdown/lib/src/builder.dart | 56 +++++++-- packages/flutter_markdown/lib/src/widget.dart | 14 +-- .../test/relative_anchors_test.dart | 106 ------------------ 3 files changed, 52 insertions(+), 124 deletions(-) delete mode 100644 packages/flutter_markdown/test/relative_anchors_test.dart diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 53df1b47399..17fa4458c06 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -85,6 +85,40 @@ 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 might 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); @@ -96,16 +130,18 @@ class IndexedAnchorData extends AnchorData { } } -class AnchorData { - AnchorData(this.id, this.text); - - final String id; - final String text; - - @override - String toString() => 'AnchorData(id: $id, text: $text)'; -} - +/// [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, diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 0f32d574f2f..de9703a1947 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -532,6 +532,8 @@ class AnchorsController { } } +/// [ItemPosition] for [AnchorData]. +/// See [ItemPosition] for more information. class AnchorPosition { AnchorPosition({ required this.anchor, @@ -548,15 +550,11 @@ class AnchorPosition { 'AnchorPosition(anchor: $anchor, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; } -/// A scrolling widget that parses and displays Markdown. -/// -/// Supports all GitHub Flavored Markdown from the -/// [specification](https://github.github.com/gfm/). +/// A scrolling widget for markdown that supports relative anchors. +/// Relative anchors are used to scroll to a specific position inside markdown +/// text. /// -/// See also: -/// -/// * [MarkdownBody], which is a non-scrolling container of Markdown. -/// * +/// The scrolling can be controlled via [AnchorController]. class RelativeAnchorsMarkdown extends MarkdownWidget { /// Creates a scrolling widget that parses and displays Markdown. const RelativeAnchorsMarkdown({ diff --git a/packages/flutter_markdown/test/relative_anchors_test.dart b/packages/flutter_markdown/test/relative_anchors_test.dart deleted file mode 100644 index 0741feee903..00000000000 --- a/packages/flutter_markdown/test/relative_anchors_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:markdown/markdown.dart'; - -import 'utils.dart'; - -void main() => defineTests(); - -void defineTests() { - testWidgets('test', (tester) async { - await tester.pumpWidget( - boilerplate( - Markdown( - extensionSet: ExtensionSet.gitHubFlavored, - data: ''' -Hello we link to the foo chapter heading [here](#foo-chapter) -Hello we link to the foo chapter text foobar [here](#foobar) - -We can also link to some a tag. - -### Foo chapter - - Hey this the foobar text that has an "a" tag before. -''', -// Doesn't work: -// Hey this the foobar text that has an "a" tag before. -// Hey this the foobar text that has an "a" tag before. -// Weil flutter kein HTML wird das einfach drinnen gelassen. - ), - ), - ); - - // expect(find.textContaining('Hello.'), findsOneWidget); - - for (final Widget widget in tester.allWidgets) { - if (widget is RichText) { - final TextSpan span = widget.text as TextSpan; - final String text = _extractTextFromTextSpan(span); - print(text); - } - } - }); - testWidgets('debugging', (tester) async { - await tester.pumpWidget( - boilerplate( - Markdown( - extensionSet: ExtensionSet.gitHubWeb, - data: ''' -link to [heading below](#first-header-with-code) -# first **header** with `code` -''', -// data: ''' -// # first **header** with `code` -// ## second header -// ### third header -// #### fourth header -// ##### fifth header -// ###### sixth header -// ''', - ), - ), - ); - - // expect(find.textContaining('Hello.'), findsOneWidget); - - for (final Widget widget in tester.allWidgets) { - if (widget is RichText) { - final TextSpan span = widget.text as TextSpan; - final String text = _extractTextFromTextSpan(span); - print(text); - } - } - }); - - test('markdown', () { - final res = markdownToHtml( - ''' -Hello we link to the foo chapter heading [here](#foo-chapter) -Hello we link to the foo chapter text foobar [here](#foobar) - -test ---- - -We can also link to some a tag. - -### Foo chapter - - Hey this the foobar text that has an "a" tag before. -''', - extensionSet: ExtensionSet.gitHubWeb, - ); - - print(res); - }); -} - -String _extractTextFromTextSpan(TextSpan span) { - String text = span.text ?? ''; - if (span.children != null) { - for (final TextSpan child in span.children! as Iterable) { - text += _extractTextFromTextSpan(child); - } - } - return text; -} From 79bcdd40daa4064686f947adec1d90d202244366 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Mon, 18 Apr 2022 17:52:23 +0200 Subject: [PATCH 05/10] Small changes for flutter WIP PR. --- .../flutter_markdown/lib/src/builder.dart | 19 ++----------------- packages/flutter_markdown/lib/src/widget.dart | 1 + packages/flutter_markdown/test/all.dart | 2 -- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 17fa4458c06..2317b55f359 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -98,7 +98,7 @@ abstract class MarkdownBuilderDelegate { /// /// 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 might have an anchor. +/// headers should have an anchor. class AnchorData { AnchorData(this.id, this.text); @@ -163,18 +163,6 @@ class Anchor extends StatelessWidget { } } -extension on md.Element { - String toReadableString() { - return 'md.Element(textContent: $textContent, tag: $tag, children: $children, attributes: $attributes, generatedId: $generatedId)'; - } -} - -extension on md.Text { - String toReadableString() { - return 'md.Text(text: $text)'; - } -} - /// Builds a [Widget] tree from parsed Markdown. /// /// See also: @@ -310,7 +298,7 @@ class MarkdownBuilder implements md.NodeVisitor { 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 anchor. + // occasion of the anchor. if (getIndexForAnchor(widget.data.id) != null) { continue; } @@ -507,9 +495,6 @@ class MarkdownBuilder implements md.NodeVisitor { ), ); } else { - // Headers: H1-H6 are built here - // # first **header** will cause this to be executed for - // "first" and "header" seperately child = _buildRichText( TextSpan( style: _isInBlockquote diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index de9703a1947..698008f4605 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -636,6 +636,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { @override Widget build(BuildContext context, List? children) { + // TODO: Temporary hack anchorsController._getIndexOfAnchor = getIndexForAnchor; anchorsController._replaceItemPositionsListener(itemPositionsListener!); anchorsController._itemScrollController = itemScrollController!; diff --git a/packages/flutter_markdown/test/all.dart b/packages/flutter_markdown/test/all.dart index cbfe4c7f425..b348f8e23ec 100644 --- a/packages/flutter_markdown/test/all.dart +++ b/packages/flutter_markdown/test/all.dart @@ -14,7 +14,6 @@ import 'image_test.dart' as image_test; import 'line_break_test.dart' as line_break_test; import 'link_test.dart' as link_test; import 'list_test.dart' as list_test; -import 'relative_anchors_test.dart' as relative_anchors; import 'scrollable_test.dart' as scrollable_test; import 'style_sheet_test.dart' as style_sheet_test; import 'table_test.dart' as table_test; @@ -34,7 +33,6 @@ void main() { line_break_test.defineTests(); link_test.defineTests(); list_test.defineTests(); - relative_anchors.defineTests(); scrollable_test.defineTests(); style_sheet_test.defineTests(); table_test.defineTests(); From 839bc85493a95be8c9b654a0a124b2e4bf963bfe Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Mon, 14 Nov 2022 17:23:05 +0100 Subject: [PATCH 06/10] Fix hacky global anchor methods. --- packages/flutter_markdown/lib/src/widget.dart | 109 +++++++++++------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 698008f4605..c97b3589751 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -151,6 +151,7 @@ abstract class MarkdownWidget extends StatefulWidget { const MarkdownWidget({ Key? key, required this.data, + this.anchorsController, this.selectable = false, this.styleSheet, this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material, @@ -267,12 +268,7 @@ abstract class MarkdownWidget extends StatefulWidget { /// specification on soft line breaks when lines of text are joined. final bool softLineBreak; - /// Index of anchor widget for children passed in [build]. - /// Used so that [ItemScrollController] can scroll to an anchor inside - /// [RelativeAnchorsMarkdown] - int? getIndexForAnchor(String anchorId) => _getIndexForAnchor(anchorId); - - List getIndexedAnchors() => _getIndexedAnchors(); + final AnchorsController? anchorsController; /// Subclasses should override this function to display the given children, /// which are the parsed representation of [data]. @@ -283,10 +279,6 @@ abstract class MarkdownWidget extends StatefulWidget { _MarkdownWidgetState createState() => _MarkdownWidgetState(); } -// TODO: Horrible hack, should be only temporary. -late int? Function(String anchorId) _getIndexForAnchor; -late List Function() _getIndexedAnchors; - class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { List? _children; @@ -351,8 +343,7 @@ class _MarkdownWidgetState extends State softLineBreak: widget.softLineBreak, ); - _getIndexForAnchor = builder.getIndexForAnchor; - _getIndexedAnchors = builder.getIndexedAnchors; + widget.anchorsController?.registerMarkdownBuilder(builder); _children = builder.build(astNodes); } @@ -407,6 +398,7 @@ class MarkdownBody extends MarkdownWidget { const MarkdownBody({ Key? key, required String data, + AnchorsController? anchorsController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -432,6 +424,7 @@ class MarkdownBody extends MarkdownWidget { }) : super( key: key, data: data, + anchorsController: anchorsController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme, @@ -470,17 +463,42 @@ class MarkdownBody extends MarkdownWidget { } class AnchorsController { - ItemPositionsListener? _itemPositionsListener; - ItemScrollController? _itemScrollController; + factory AnchorsController({ + ItemPositionsListener? itemPositionsListener, + ItemScrollController? itemScrollController, + }) { + return AnchorsController._( + itemPositionsListener ?? ItemPositionsListener.create(), + itemScrollController ?? ItemScrollController(), + ); + } + + AnchorsController._( + this._itemPositionsListener, + this._itemScrollController, + ) { + _itemPositionsListener.itemPositions.addListener(() { + _anchorPositions.value = + _filterAnchorPositions(_itemPositionsListener.itemPositions.value); + }); + } + + final ItemPositionsListener _itemPositionsListener; + final ItemScrollController _itemScrollController; int? Function(String anchorId)? _getIndexOfAnchor; List Function()? _getIndexedAnchors; - List? _indexedAnchors; + // TODO: We used this before? Should we cache _getIndexedAnchors? + // List? _indexedAnchors; + List get _indexedAnchors => _getIndexedAnchors != null + ? _getIndexedAnchors!() + : []; - ValueNotifier> _anchorPositions = - ValueNotifier>(const []); + final ValueNotifier> _anchorPositions = + ValueNotifier>(const []); - List getIndexedAnchors() { - return _indexedAnchors ?? []; + void registerMarkdownBuilder(MarkdownBuilder builder) { + _getIndexOfAnchor = builder.getIndexForAnchor; + _getIndexedAnchors = builder.getIndexedAnchors; } Future scrollToAnchor(String anchorId) { @@ -488,7 +506,7 @@ class AnchorsController { if (index == null) { throw ArgumentError('Unknown anchorId'); } - return _itemScrollController!.scrollTo( + return _itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 100), ); @@ -499,31 +517,21 @@ class AnchorsController { return _anchorPositions; } - void _replaceItemPositionsListener( - ItemPositionsListener itemPositionsListener) { - _itemPositionsListener = itemPositionsListener; - // We should also remove the previous listener closure of the old ItemPositionsListener - _itemPositionsListener!.itemPositions.addListener(() { - _indexedAnchors = _getIndexedAnchors!(); - _anchorPositions.value = - _filterAnchorPositions(itemPositionsListener.itemPositions.value); - }); - } - Iterable _filterAnchorPositions( Iterable itemPositions) { - List _anchorPositions = []; + final List _anchorPositions = []; - for (final itemPosition in itemPositions) { - final anchorsWithIndex = _indexedAnchors! - .where((indexedAnchor) => itemPosition.index == indexedAnchor.index); + for (final ItemPosition itemPosition in itemPositions) { + final Iterable anchorsWithIndex = + _indexedAnchors.where((IndexedAnchorData indexedAnchor) => + itemPosition.index == indexedAnchor.index); if (anchorsWithIndex.isEmpty) { continue; } - final anchorWithIndex = anchorsWithIndex.first; + _anchorPositions.add(AnchorPosition( - anchor: anchorWithIndex, + anchor: anchorsWithIndex.first, itemLeadingEdge: itemPosition.itemLeadingEdge, itemTrailingEdge: itemPosition.itemTrailingEdge)); } @@ -532,8 +540,18 @@ class AnchorsController { } } -/// [ItemPosition] for [AnchorData]. -/// See [ItemPosition] for more information. +/// The Position of an Anchor on screen. +/// Can be observed by using [AnchorsController.anchorPositions]. +/// ```dart +/// // Table of contents heading at the top of the screen +/// AnchorPosition( +/// anchor: AnchorData('table-of-contents', 'table of contents'), +/// itemLeadingEdge: 0.0, +/// itemTrailingEdge: 0.1, +/// ); +/// ``` +/// Akin to an [ItemPosition] returned by [ScrollablePositionedList], +/// but only specific for [AnchorData]. class AnchorPosition { AnchorPosition({ required this.anchor, @@ -560,6 +578,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { const RelativeAnchorsMarkdown({ Key? key, required String data, + required this.anchorsController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -582,13 +601,13 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { this.padding = const EdgeInsets.all(16.0), this.itemPositionsListener, this.itemScrollController, - required this.anchorsController, this.physics, this.shrinkWrap = false, bool softLineBreak = false, }) : super( key: key, data: data, + anchorsController: anchorsController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme, @@ -637,10 +656,10 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { @override Widget build(BuildContext context, List? children) { // TODO: Temporary hack - anchorsController._getIndexOfAnchor = getIndexForAnchor; - anchorsController._replaceItemPositionsListener(itemPositionsListener!); - anchorsController._itemScrollController = itemScrollController!; - anchorsController._getIndexedAnchors = getIndexedAnchors; + // anchorsController._getIndexOfAnchor = getIndexForAnchor; + // anchorsController._replaceItemPositionsListener(itemPositionsListener!); + // anchorsController._itemScrollController = itemScrollController!; + // anchorsController._getIndexedAnchors = getIndexedAnchors; children!; return ScrollablePositionedList.builder( @@ -669,6 +688,7 @@ class Markdown extends MarkdownWidget { const Markdown({ Key? key, required String data, + AnchorsController? anchorsController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -696,6 +716,7 @@ class Markdown extends MarkdownWidget { }) : super( key: key, data: data, + anchorsController: anchorsController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme, From 3533884ef0935761d37a91dc95a9a79b58790dc3 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Mon, 14 Nov 2022 17:27:22 +0100 Subject: [PATCH 07/10] Remove commented out code. --- packages/flutter_markdown/lib/src/widget.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index c97b3589751..26dfe962079 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -655,12 +655,6 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { @override Widget build(BuildContext context, List? children) { - // TODO: Temporary hack - // anchorsController._getIndexOfAnchor = getIndexForAnchor; - // anchorsController._replaceItemPositionsListener(itemPositionsListener!); - // anchorsController._itemScrollController = itemScrollController!; - // anchorsController._getIndexedAnchors = getIndexedAnchors; - children!; return ScrollablePositionedList.builder( padding: padding, From 4b9e5ca382e48b61157f8745ee4141ebf4beb225 Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Tue, 15 Nov 2022 19:58:33 +0100 Subject: [PATCH 08/10] Add ItemScrollController.scrollTo arguments to AnchorsController.scrollTo. --- packages/flutter_markdown/lib/src/widget.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 26dfe962079..ed81f2108d2 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -501,14 +501,23 @@ class AnchorsController { _getIndexedAnchors = builder.getIndexedAnchors; } - Future scrollToAnchor(String anchorId) { + Future scrollToAnchor( + String anchorId, { + double alignment = 0, + required Duration duration, + Curve curve = Curves.linear, + List opacityAnimationWeights = const [40, 20, 40], + }) { final int? index = _getIndexOfAnchor!(anchorId); if (index == null) { throw ArgumentError('Unknown anchorId'); } return _itemScrollController.scrollTo( index: index, - duration: const Duration(milliseconds: 100), + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, ); } From 5e43ea63ece8f3c3d6e79ed5c56c0779533426fa Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Thu, 17 Nov 2022 13:55:50 +0100 Subject: [PATCH 09/10] Use itemPositionsListener and itemScrollController of AnchorsController. --- packages/flutter_markdown/lib/src/widget.dart | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index ed81f2108d2..2530a023222 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -608,8 +608,6 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline, this.padding = const EdgeInsets.all(16.0), - this.itemPositionsListener, - this.itemScrollController, this.physics, this.shrinkWrap = false, bool softLineBreak = false, @@ -639,16 +637,6 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { /// The amount of space by which to inset the children. final EdgeInsets padding; - /// Provides a listenable iterable of [itemPositions] of items that are on - /// screen and their locations. - final ItemPositionsListener? itemPositionsListener; - - /// An object that can be used to jump or scroll to a particular position in - /// the [RelativeAnchorsMarkdown] widget. - /// - /// See also: [ScrollablePositionedList.itemScrollController] - final ItemScrollController? itemScrollController; - final AnchorsController anchorsController; /// How the scroll view should respond to user input. @@ -671,8 +659,8 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { itemBuilder: (BuildContext context, int index) => children[index], physics: physics, shrinkWrap: shrinkWrap, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, + itemScrollController: anchorsController._itemScrollController, + itemPositionsListener: anchorsController._itemPositionsListener, ); } } From a12da208dff84bc9c77453a1e8a392bb7f40fc4a Mon Sep 17 00:00:00 2001 From: Jonas Sander <29028262+Jonas-Sander@users.noreply.github.com> Date: Sun, 20 Nov 2022 17:09:36 +0100 Subject: [PATCH 10/10] Rename AnchorsController to AnchorController. --- packages/flutter_markdown/lib/src/widget.dart | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 2530a023222..4ad27624b52 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -151,7 +151,7 @@ abstract class MarkdownWidget extends StatefulWidget { const MarkdownWidget({ Key? key, required this.data, - this.anchorsController, + this.anchorController, this.selectable = false, this.styleSheet, this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material, @@ -268,7 +268,7 @@ abstract class MarkdownWidget extends StatefulWidget { /// specification on soft line breaks when lines of text are joined. final bool softLineBreak; - final AnchorsController? anchorsController; + final AnchorController? anchorController; /// Subclasses should override this function to display the given children, /// which are the parsed representation of [data]. @@ -343,7 +343,7 @@ class _MarkdownWidgetState extends State softLineBreak: widget.softLineBreak, ); - widget.anchorsController?.registerMarkdownBuilder(builder); + widget.anchorController?.registerMarkdownBuilder(builder); _children = builder.build(astNodes); } @@ -398,7 +398,7 @@ class MarkdownBody extends MarkdownWidget { const MarkdownBody({ Key? key, required String data, - AnchorsController? anchorsController, + AnchorController? anchorController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -424,7 +424,7 @@ class MarkdownBody extends MarkdownWidget { }) : super( key: key, data: data, - anchorsController: anchorsController, + anchorController: anchorController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme, @@ -462,18 +462,18 @@ class MarkdownBody extends MarkdownWidget { } } -class AnchorsController { - factory AnchorsController({ +class AnchorController { + factory AnchorController({ ItemPositionsListener? itemPositionsListener, ItemScrollController? itemScrollController, }) { - return AnchorsController._( + return AnchorController._( itemPositionsListener ?? ItemPositionsListener.create(), itemScrollController ?? ItemScrollController(), ); } - AnchorsController._( + AnchorController._( this._itemPositionsListener, this._itemScrollController, ) { @@ -550,7 +550,7 @@ class AnchorsController { } /// The Position of an Anchor on screen. -/// Can be observed by using [AnchorsController.anchorPositions]. +/// Can be observed by using [AnchorController.anchorPositions]. /// ```dart /// // Table of contents heading at the top of the screen /// AnchorPosition( @@ -587,7 +587,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { const RelativeAnchorsMarkdown({ Key? key, required String data, - required this.anchorsController, + required this.anchorController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -614,7 +614,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { }) : super( key: key, data: data, - anchorsController: anchorsController, + anchorController: anchorController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme, @@ -637,7 +637,7 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { /// The amount of space by which to inset the children. final EdgeInsets padding; - final AnchorsController anchorsController; + final AnchorController anchorController; /// How the scroll view should respond to user input. /// @@ -659,8 +659,8 @@ class RelativeAnchorsMarkdown extends MarkdownWidget { itemBuilder: (BuildContext context, int index) => children[index], physics: physics, shrinkWrap: shrinkWrap, - itemScrollController: anchorsController._itemScrollController, - itemPositionsListener: anchorsController._itemPositionsListener, + itemScrollController: anchorController._itemScrollController, + itemPositionsListener: anchorController._itemPositionsListener, ); } } @@ -679,7 +679,7 @@ class Markdown extends MarkdownWidget { const Markdown({ Key? key, required String data, - AnchorsController? anchorsController, + AnchorController? anchorController, bool selectable = false, MarkdownStyleSheet? styleSheet, MarkdownStyleSheetBaseTheme? styleSheetTheme, @@ -707,7 +707,7 @@ class Markdown extends MarkdownWidget { }) : super( key: key, data: data, - anchorsController: anchorsController, + anchorController: anchorController, selectable: selectable, styleSheet: styleSheet, styleSheetTheme: styleSheetTheme,