Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, GlobalKey> keys = <String, GlobalKey>{};

@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
Expand Down
201 changes: 201 additions & 0 deletions example/lib/demos/anchor_link_demo.dart
Original file line number Diff line number Diff line change
@@ -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<String> get data => Future<String>.value(_data);

@override
Future<String> get notes => Future<String>.value(_notes);

@override
State<AnchorLinkDemo> createState() => _AnchorLinkDemoState();
}

class _AnchorLinkDemoState extends State<AnchorLinkDemo> {
final HeadingAnchorBuilder _anchors = HeadingAnchorBuilder();

@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: widget.data,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: MarkdownBody(
data: snapshot.data!,
builders: <String, MarkdownElementBuilder>{
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<String> headingTags = <String>['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;
}
2 changes: 2 additions & 0 deletions example/lib/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -32,6 +33,7 @@ class HomeScreen extends StatelessWidget {
const CenteredHeaderDemo(),
const MarkdownBodyShrinkWrapDemo(),
const CustomBulletListDemo(),
const AnchorLinkDemo(),
];

@override
Expand Down
33 changes: 20 additions & 13 deletions lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 15 additions & 1 deletion lib/src/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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.
///
Expand Down
2 changes: 2 additions & 0 deletions test/all.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -46,4 +47,5 @@ void main() {
text_alignment_test.defineTests();
text_scaler.defineTests();
uri_test.defineTests();
wrap_block_widget_test.defineTests();
}
Loading