From f06d3ee44889d1cefcf91a7de9bb273b9d4b614e Mon Sep 17 00:00:00 2001 From: Daiki Kajiwara Date: Thu, 3 Sep 2026 16:20:00 +0900 Subject: [PATCH] feat: add MarkdownElementBuilder.wrapBlockWidget to wrap block widgets without replacing them Returning a widget from visitElementAfterWithContext replaces an element's default rendering entirely, which makes it impossible to attach a key (or any other wrapper) to a heading while keeping its inline formatting, style sheet styles and paddingBuilders padding. That is the building block needed for scrolling to `#fragment` links (#124). - Add `wrapBlockWidget(context, element, child)` to MarkdownElementBuilder. It is called for block elements with the fully built widget (including list bullets and blockquote / code block decoration) and returns `child` unchanged by default. - Fall back to the default text rendering when a block builder's `visitText` returns null, so a builder can override only `wrapBlockWidget` and still get the element's text rendered. - Add tests, an anchor link demo to the example app, and a README example. --- README.md | 21 ++ example/lib/demos/anchor_link_demo.dart | 201 ++++++++++++++++ example/lib/screens/home_screen.dart | 2 + lib/src/builder.dart | 33 +-- lib/src/widget.dart | 16 +- test/all.dart | 2 + test/wrap_block_widget_test.dart | 290 ++++++++++++++++++++++++ 7 files changed, 551 insertions(+), 14 deletions(-) create mode 100644 example/lib/demos/anchor_link_demo.dart create mode 100644 test/wrap_block_widget_test.dart diff --git a/README.md b/README.md index 9365cfe..4f2bcae 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,27 @@ For full control over how a specific tag renders, supply a `code`, `a`, or custom tags. See the [`example/`](example/) app for working custom-builder demos. +Returning a widget from `visitElementAfterWithContext` replaces the element's +default rendering. To keep it and only wrap it, override `wrapBlockWidget` +instead; it receives the fully built block. For example, to give headings a key +that `#fragment` links can scroll to: + +```dart +class HeadingAnchorBuilder extends MarkdownElementBuilder { + final Map keys = {}; + + @override + Widget wrapBlockWidget(BuildContext context, md.Element element, Widget child) { + return KeyedSubtree(key: keys.putIfAbsent(element.textContent, GlobalKey.new), child: child); + } +} +``` + +Then resolve the fragment in `onTapLink` and call `Scrollable.ensureVisible` on +`keys[fragment]?.currentContext`. The target heading must be laid out for this, +so use `MarkdownBody` or `Markdown(shrinkWrap: true)`; the anchor link demo in +[`example/`](example/) shows a complete version. + ## Selection By default, Markdown is not selectable. A caller may use the following ways to diff --git a/example/lib/demos/anchor_link_demo.dart b/example/lib/demos/anchor_link_demo.dart new file mode 100644 index 0000000..de741e4 --- /dev/null +++ b/example/lib/demos/anchor_link_demo.dart @@ -0,0 +1,201 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_implementing_value_types + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +import '../shared/markdown_demo_widget.dart'; + +// ignore_for_file: public_member_api_docs + +const String _filler = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu +fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. +'''; + +// Markdown source data with a table of contents made of anchor links. +const String _data = ''' +# Anchor Links + +- [Getting Started](#getting-started) +- [Configuration](#configuration) +- [**Bold** Heading](#bold-heading) +- [Back to top](#anchor-links) + +## Getting Started + +$_filler +$_filler + +## Configuration + +$_filler +$_filler + +## **Bold** Heading + +$_filler +$_filler + +[Back to top](#anchor-links) +'''; + +const String _notes = ''' +# Anchor Link Demo +--- + +## Overview + +Markdown documents often start with a table of contents whose links point at +headings in the same document, such as `[Getting Started](#getting-started)`. +Flutter has no built-in notion of an anchor, so `flutter_markdown_plus` hands +the `#getting-started` href to `onTapLink` and leaves the rest to you. + +This demo shows the two pieces needed to make such links scroll: + +1. A `MarkdownElementBuilder` for the heading tags that overrides + `wrapBlockWidget`. The hook receives the fully rendered heading (bold text, + style sheet styles and padding included) and wraps it in a small widget that + registers its `BuildContext` under the heading's slug. Because the default + rendering is kept, the builder does not have to re-implement headings. +2. An `onTapLink` handler that looks up the slug from a `#fragment` href and + calls `Scrollable.ensureVisible` on the registered context. + +## Notes + +- `Markdown` lays its blocks out lazily in a `ListView`, so off-screen + headings have no `BuildContext` to scroll to. Use `MarkdownBody` inside a + `SingleChildScrollView` (as this demo does) or `Markdown` with + `shrinkWrap: true`. +- The slug here is the heading text in lower case with spaces replaced by + hyphens. Match whatever convention your content authors use. +- Headings with the same text register under the same slug; links resolve to + the first one in the document, like browsers do. +'''; + +class AnchorLinkDemo extends StatefulWidget implements MarkdownDemoWidget { + const AnchorLinkDemo({super.key}); + + static const String _title = 'Anchor Link Demo'; + + @override + String get title => AnchorLinkDemo._title; + + @override + String get description => 'An example of scrolling to a heading when a ' + '`#fragment` link is tapped, using wrapBlockWidget.'; + + @override + Future get data => Future.value(_data); + + @override + Future get notes => Future.value(_notes); + + @override + State createState() => _AnchorLinkDemoState(); +} + +class _AnchorLinkDemoState extends State { + final HeadingAnchorBuilder _anchors = HeadingAnchorBuilder(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: widget.data, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const CircularProgressIndicator(); + } + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: MarkdownBody( + data: snapshot.data!, + builders: { + for (final String tag in HeadingAnchorBuilder.headingTags) tag: _anchors, + }, + onTapLink: (String text, String? href, String title) { + final BuildContext? target = _anchors.contextFor(href); + if (target != null) { + Scrollable.ensureVisible( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + ); + }, + ); + } +} + +/// Registers every heading under its slug so `#fragment` links can be resolved +/// to a [BuildContext]. +/// +/// Headings are tracked by a small stateful wrapper rather than by +/// [GlobalKey]s: repeated heading text would otherwise produce duplicate keys, +/// and the registry stays correct when the markdown is re-parsed. +class HeadingAnchorBuilder extends MarkdownElementBuilder { + static const List headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + + final List<_HeadingAnchorState> _anchors = <_HeadingAnchorState>[]; + + /// Returns the context of the first heading matching [href], or null when + /// [href] is not a fragment link or no heading matches. + BuildContext? contextFor(String? href) { + if (href == null || !href.startsWith('#')) { + return null; + } + final String slug = Uri.decodeComponent(href.substring(1)); + for (final _HeadingAnchorState anchor in _anchors) { + if (anchor.widget.slug == slug && anchor.mounted) { + return anchor.context; + } + } + return null; + } + + @override + Widget wrapBlockWidget(BuildContext context, md.Element element, Widget child) { + return _HeadingAnchor(registry: this, slug: slugify(element.textContent), child: child); + } + + static String slugify(String text) => text.trim().toLowerCase().replaceAll(' ', '-'); +} + +class _HeadingAnchor extends StatefulWidget { + const _HeadingAnchor({required this.registry, required this.slug, required this.child}); + + final HeadingAnchorBuilder registry; + final String slug; + final Widget child; + + @override + State<_HeadingAnchor> createState() => _HeadingAnchorState(); +} + +class _HeadingAnchorState extends State<_HeadingAnchor> { + @override + void initState() { + super.initState(); + widget.registry._anchors.add(this); + } + + @override + void dispose() { + widget.registry._anchors.remove(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/example/lib/screens/home_screen.dart b/example/lib/screens/home_screen.dart index 4543dad..ec50d65 100644 --- a/example/lib/screens/home_screen.dart +++ b/example/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import '../demos/anchor_link_demo.dart'; import '../demos/basic_markdown_demo.dart'; import '../demos/centered_header_demo.dart'; import '../demos/custom_bullet_list_demo.dart'; @@ -32,6 +33,7 @@ class HomeScreen extends StatelessWidget { const CenteredHeaderDemo(), const MarkdownBodyShrinkWrapDemo(), const CustomBulletListDemo(), + const AnchorLinkDemo(), ]; @override diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 3e965bd..c518a95 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -344,10 +344,15 @@ class MarkdownBuilder implements md.NodeVisitor { return text.replaceAll(softLineBreakPattern, ' '); } + // A builder registered for the current block may render the text itself. + // When it returns null the default rendering below is used, so a builder + // can override only wrapBlockWidget (or visitElementBefore) and still get + // the element's text rendered. Widget? child; if (_blocks.isNotEmpty && builders.containsKey(_blocks.last.tag)) { child = builders[_blocks.last.tag!]!.visitText(text, styleSheet.styles[_blocks.last.tag!]); - } else if (_blocks.last.tag == 'pre') { + } + if (child == null && _blocks.last.tag == 'pre') { child = _ScrollControllerBuilder( builder: (BuildContext context, ScrollController preScrollController, Widget? child) { return Scrollbar( @@ -361,19 +366,16 @@ class MarkdownBuilder implements md.NodeVisitor { ); }, child: _buildRichText(delegate.formatText(styleSheet, text.text))); - } else { - child = _buildRichText( - TextSpan( - style: _inlines.last.style, - text: trimText(text.text), - recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, - ), - textAlign: _textAlignForBlockTag(_currentBlockTag), - ); - } - if (child != null) { - _inlines.last.children.add(child); } + child ??= _buildRichText( + TextSpan( + style: _inlines.last.style, + text: trimText(text.text), + recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, + ), + textAlign: _textAlignForBlockTag(_currentBlockTag), + ); + _inlines.last.children.add(child); _lastVisitedTag = null; } @@ -492,6 +494,11 @@ class MarkdownBuilder implements md.NodeVisitor { ); } + final MarkdownElementBuilder? builder = builders[tag]; + if (builder != null) { + child = builder.wrapBlockWidget(delegate.context, element, child); + } + _addBlockChild(child); } else { final _InlineElement current = _inlines.removeLast(); diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 5f2dfec..6a601d0 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -105,7 +105,8 @@ abstract class MarkdownElementBuilder { /// If [MarkdownWidget.styleSheet] has a style of this tag, will passing /// to [preferredStyle]. /// - /// If you needn't build a widget, return null. + /// If you needn't build a widget, return null; the text is then rendered + /// with the default text rendering for its block. Widget? visitText(md.Text text, TextStyle? preferredStyle) => null; /// Called when an Element has been reached, after its children have been @@ -118,6 +119,9 @@ abstract class MarkdownElementBuilder { /// [parentStyle]. /// /// If a widget build isn't needed, return null. + /// + /// See also [wrapBlockWidget], which wraps the default rendering instead of + /// replacing it. Widget? visitElementAfterWithContext( BuildContext context, md.Element element, @@ -127,6 +131,16 @@ abstract class MarkdownElementBuilder { return visitElementAfter(element, preferredStyle); } + /// Called when a block element's widget has been built, so it can be wrapped + /// without replacing the default rendering. + /// + /// [child] is the fully built widget, either the default one or the one + /// returned by [visitElementAfterWithContext]. Not called for inline + /// elements. + /// + /// Returns [child] unchanged by default. + Widget wrapBlockWidget(BuildContext context, md.Element element, Widget child) => child; + /// Called when an Element has been reached, after its children have been /// visited. /// diff --git a/test/all.dart b/test/all.dart index 9488a6b..799d922 100644 --- a/test/all.dart +++ b/test/all.dart @@ -23,6 +23,7 @@ import 'text_alignment_test.dart' as text_alignment_test; import 'text_scaler_test.dart' as text_scaler; import 'text_test.dart' as text_test; import 'uri_test.dart' as uri_test; +import 'wrap_block_widget_test.dart' as wrap_block_widget_test; void main() { blockquote_test.defineTests(); @@ -46,4 +47,5 @@ void main() { text_alignment_test.defineTests(); text_scaler.defineTests(); uri_test.defineTests(); + wrap_block_widget_test.defineTests(); } diff --git a/test/wrap_block_widget_test.dart b/test/wrap_block_widget_test.dart new file mode 100644 index 0000000..cc5793f --- /dev/null +++ b/test/wrap_block_widget_test.dart @@ -0,0 +1,290 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('wrapBlockWidget', () { + testWidgets( + 'wraps the default heading widget and keeps its inline formatting', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final _RecordingBuilder builder = _RecordingBuilder(wrapWith: key); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '# **Bold** and [link](https://example.com) header', + builders: {'h1': builder}, + onTapLink: (String text, String? href, String title) {}, + ), + ), + ); + + expect(builder.wrappedElements.map((md.Element e) => e.tag), ['h1']); + + // The default heading is still rendered, now as a descendant of the wrapper. + final Finder wrapper = find.byKey(key); + expect(wrapper, findsOneWidget); + final Finder richText = find.descendant(of: wrapper, matching: find.byType(RichText)); + expect(richText, findsOneWidget); + expectTextStrings([tester.widget(richText)], ['Bold and link header']); + + // Inline formatting inside the heading survives: bold style and link recognizer. + final TextSpan span = tester.widget(richText).text as TextSpan; + final List children = []; + span.visitChildren((InlineSpan child) { + if (child is TextSpan && child.text != null) { + children.add(child); + } + return true; + }); + expect(children.map((TextSpan s) => s.text), ['Bold', ' and ', 'link', ' header']); + expect(children[0].style?.fontWeight, FontWeight.bold); + expect(children[2].recognizer, isA()); + }, + ); + + testWidgets( + 'keeps the paddingBuilders padding inside the wrapped widget', + (WidgetTester tester) async { + const EdgeInsets padding = EdgeInsets.all(17); + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '# Header', + builders: {'h1': _RecordingBuilder(wrapWith: key)}, + paddingBuilders: {'h1': _FixedPaddingBuilder(padding)}, + ), + ), + ); + + final Finder padded = find.descendant( + of: find.byKey(key), + matching: find.byWidgetPredicate((Widget w) => w is Padding && w.padding == padding), + ); + expect(padded, findsOneWidget); + }, + ); + + testWidgets( + 'receives the widget returned by visitElementAfterWithContext', + (WidgetTester tester) async { + final _RecordingBuilder builder = _RecordingBuilder( + wrapWith: GlobalKey(), + replacement: const ColoredBox(color: Colors.red, child: Text('custom')), + ); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '# Header', + builders: {'h1': builder}, + ), + ), + ); + + expect(builder.wrappedChildren, hasLength(1)); + expect(builder.wrappedChildren.single, isA()); + expect(find.text('custom'), findsOneWidget); + expect(find.text('Header'), findsNothing); + }, + ); + + testWidgets( + 'receives the fully built list item including its bullet', + (WidgetTester tester) async { + final _RecordingBuilder builder = _RecordingBuilder(wrapWith: GlobalKey()); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '- item', + builders: {'li': builder}, + ), + ), + ); + + expect(builder.wrappedChildren, hasLength(1)); + // The list item child is the bullet + content row, not just the content. + expect(builder.wrappedChildren.single, isA()); + expect(find.text('item'), findsOneWidget); + }, + ); + + testWidgets( + 'is not called for inline elements', + (WidgetTester tester) async { + final _RecordingBuilder builder = _RecordingBuilder(wrapWith: GlobalKey()); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: 'some *emphasis* here', + builders: {'em': builder}, + ), + ), + ); + + expect(builder.wrappedElements, isEmpty); + expectTextStrings(tester.allWidgets, ['some emphasis here']); + }, + ); + + testWidgets( + 'is called for custom block syntaxes', + (WidgetTester tester) async { + final _RecordingBuilder builder = _RecordingBuilder(wrapWith: GlobalKey(), isBlock: true); + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '[!NOTE] note block', + extensionSet: md.ExtensionSet.none, + blockSyntaxes: [_NoteSyntax()], + builders: {'note': builder}, + ), + ), + ); + + expect(builder.wrappedElements.map((md.Element e) => e.tag), ['note']); + expect(find.text('note block'), findsOneWidget); + }, + ); + + testWidgets( + 'lets anchor links scroll to a heading through a GlobalKey', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + final _AnchorBuilder anchors = _AnchorBuilder(); + final String filler = List.filled(30, 'Filler paragraph.').join('\n\n'); + await tester.pumpWidget( + boilerplate( + SingleChildScrollView( + controller: controller, + child: MarkdownBody( + data: '[Jump to target](#target)\n\n$filler\n\n## Target\n\n$filler', + builders: {'h2': anchors}, + onTapLink: (String text, String? href, String title) { + final BuildContext? context = anchors.contextFor(href); + if (context != null) { + Scrollable.ensureVisible(context); + } + }, + ), + ), + ), + ); + + expect(controller.offset, 0.0); + expect(tester.getTopLeft(find.text('Target')).dy, greaterThan(600)); + + _tapLink(tester, 'Jump to target'); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0.0)); + expect(tester.getTopLeft(find.text('Target')).dy, moreOrLessEquals(0.0, epsilon: 1.0)); + }, + ); + }); +} + +/// Taps the link with the given text by invoking its [TapGestureRecognizer]. +void _tapLink(WidgetTester tester, String linkText) { + for (final RichText richText in tester.widgetList(find.byType(RichText))) { + bool tapped = false; + richText.text.visitChildren((InlineSpan span) { + if (span is TextSpan && span.text == linkText && span.recognizer is TapGestureRecognizer) { + (span.recognizer! as TapGestureRecognizer).onTap!(); + tapped = true; + return false; + } + return true; + }); + if (tapped) { + return; + } + } + fail('No link with text "$linkText" found.'); +} + +/// Records every call to [wrapBlockWidget] and wraps the child in a [KeyedSubtree]. +class _RecordingBuilder extends MarkdownElementBuilder { + _RecordingBuilder({required this.wrapWith, this.replacement, this.isBlock = false}); + + final GlobalKey wrapWith; + final Widget? replacement; + final bool isBlock; + final List wrappedElements = []; + final List wrappedChildren = []; + + @override + bool isBlockElement() => isBlock; + + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + return replacement; + } + + @override + Widget wrapBlockWidget(BuildContext context, md.Element element, Widget child) { + wrappedElements.add(element); + wrappedChildren.add(child); + return KeyedSubtree(key: wrapWith, child: child); + } +} + +/// Gives every heading a [GlobalKey] so `#fragment` links can scroll to it. +class _AnchorBuilder extends MarkdownElementBuilder { + final Map _keys = {}; + + BuildContext? contextFor(String? href) { + if (href == null || !href.startsWith('#')) { + return null; + } + return _keys[href.substring(1)]?.currentContext; + } + + @override + Widget wrapBlockWidget(BuildContext context, md.Element element, Widget child) { + final String anchor = element.textContent.trim().toLowerCase(); + final GlobalKey key = _keys.putIfAbsent(anchor, GlobalKey.new); + return KeyedSubtree(key: key, child: child); + } +} + +class _FixedPaddingBuilder extends MarkdownPaddingBuilder { + _FixedPaddingBuilder(this.padding); + + final EdgeInsets padding; + + @override + EdgeInsets getPadding() => padding; +} + +class _NoteSyntax extends md.BlockSyntax { + @override + md.Node? parse(md.BlockParser parser) { + final md.Line line = parser.current; + parser.advance(); + return md.Element('note', [md.Text(line.content.substring(8))]); + } + + @override + RegExp get pattern => RegExp(r'^\[!NOTE] '); +}