diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md index e0fe8c99c3f..3ce5c52c1a7 100644 --- a/packages/flutter_markdown/CHANGELOG.md +++ b/packages/flutter_markdown/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.21+1 + +* Adds `onSelectionChanged` to the constructors of `Markdown` and `MarkdownBody`. + ## 0.6.21 * Fixes support for `WidgetSpan` in `Text.rich` elements inside `MarkdownElementBuilder`. diff --git a/packages/flutter_markdown/README.md b/packages/flutter_markdown/README.md index 6a97d169e7f..93cc6f50b8c 100644 --- a/packages/flutter_markdown/README.md +++ b/packages/flutter_markdown/README.md @@ -72,6 +72,15 @@ but it's possible to create your own custom styling. Use the MarkdownStyle class to pass in your own style. If you don't want to use Markdown outside of material design, use the MarkdownRaw class. +## Selection + +By default, Markdown is not selectable. A caller may use the following ways to +customize the selection behavior of Markdown: + +* Set `selectable` to true, and use `onTapText` and `onSelectionChanged` to + handle tapping and selecting events. +* Set `selectable` to false, and wrap Markdown with [`SelectionArea`](https://api.flutter.dev/flutter/material/SelectionArea-class.html) or [`SelectionRegion`](https://api.flutter.dev/flutter/widgets/SelectableRegion-class.html). + ## Emoji Support Emoji glyphs can be included in the formatted text displayed by the Markdown diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 6188099429f..8ecdd7b9b3f 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -112,6 +112,7 @@ class MarkdownBuilder implements md.NodeVisitor { required this.paddingBuilders, required this.listItemCrossAxisAlignment, this.fitContent = false, + this.onSelectionChanged, this.onTapText, this.softLineBreak = false, }); @@ -155,6 +156,9 @@ class MarkdownBuilder implements md.NodeVisitor { /// does not allow for intrinsic height measurements. final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment; + /// Called when the user changes selection when [selectable] is set to true. + final MarkdownOnSelectionChangedCallback? onSelectionChanged; + /// Default tap handler used when [selectable] is set to true final VoidCallback? onTapText; @@ -942,6 +946,9 @@ class MarkdownBuilder implements md.NodeVisitor { text!, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, + onSelectionChanged: + (TextSelection selection, SelectionChangedCause? cause) => + onSelectionChanged!(text.text, selection, cause), onTap: onTapText, key: k, ); diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 3feee6b70f5..8edc949242e 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -12,6 +12,18 @@ import 'package:markdown/markdown.dart' as md; import '../flutter_markdown.dart'; import '_functions_io.dart' if (dart.library.html) '_functions_web.dart'; +/// Signature for callbacks used by [MarkdownWidget] when +/// [MarkdownWidget.selectable] is set to true and the user changes selection. +/// +/// The callback will return the entire block of text available for selection, +/// along with the current [selection] and the [cause] of the selection change. +/// This is a wrapper of [SelectionChangedCallback] with additional context +/// [text] for the caller to process. +/// +/// Used by [MarkdownWidget.onSelectionChanged] +typedef MarkdownOnSelectionChangedCallback = void Function( + String? text, TextSelection selection, SelectionChangedCause? cause); + /// Signature for callbacks used by [MarkdownWidget] when the user taps a link. /// The callback will return the link text, destination, and title from the /// Markdown link tag in the document. @@ -173,6 +185,7 @@ abstract class MarkdownWidget extends StatefulWidget { this.styleSheet, this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material, this.syntaxHighlighter, + this.onSelectionChanged, this.onTapLink, this.onTapText, this.imageDirectory, @@ -216,6 +229,9 @@ abstract class MarkdownWidget extends StatefulWidget { /// Called when the user taps a link. final MarkdownTapLinkCallback? onTapLink; + /// Called when the user changes selection when [selectable] is set to true. + final MarkdownOnSelectionChangedCallback? onSelectionChanged; + /// Default tap handler used when [selectable] is set to true final VoidCallback? onTapText; @@ -353,6 +369,7 @@ class _MarkdownWidgetState extends State paddingBuilders: widget.paddingBuilders, fitContent: widget.fitContent, listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment, + onSelectionChanged: widget.onSelectionChanged, onTapText: widget.onTapText, softLineBreak: widget.softLineBreak, ); @@ -415,6 +432,7 @@ class MarkdownBody extends MarkdownWidget { super.styleSheet, super.styleSheetTheme = null, super.syntaxHighlighter, + super.onSelectionChanged, super.onTapLink, super.onTapText, super.imageDirectory, @@ -469,6 +487,7 @@ class Markdown extends MarkdownWidget { super.styleSheet, super.styleSheetTheme = null, super.syntaxHighlighter, + super.onSelectionChanged, super.onTapLink, super.onTapText, super.imageDirectory, diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml index 9811a80d213..20639b245fa 100644 --- a/packages/flutter_markdown/pubspec.yaml +++ b/packages/flutter_markdown/pubspec.yaml @@ -4,7 +4,7 @@ description: A Markdown renderer for Flutter. Create rich text output, 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.21 +version: 0.6.21+1 environment: sdk: ^3.3.0 diff --git a/packages/flutter_markdown/test/text_test.dart b/packages/flutter_markdown/test/text_test.dart index cb4610c3f5c..27f16cc9004 100644 --- a/packages/flutter_markdown/test/text_test.dart +++ b/packages/flutter_markdown/test/text_test.dart @@ -2,6 +2,7 @@ // 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/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -282,6 +283,62 @@ void defineTests() { expect(textTapResults == 'Text has been tapped.', true); }, ); + + testWidgets( + 'header with line of text and onSelectionChanged callback', + (WidgetTester tester) async { + const String data = '# abc def ghi\njkl opq'; + String? selectableText; + String? selectedText; + void onSelectionChanged(String? text, TextSelection selection, + SelectionChangedCause? cause) { + selectableText = text; + selectedText = text != null ? selection.textInside(text) : null; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MarkdownBody( + data: data, + selectable: true, + onSelectionChanged: onSelectionChanged, + ), + ), + ), + ); + + // Find the positions before character 'd' and 'f'. + final Offset dPos = positionInRenderedText(tester, 'abc def ghi', 4); + final Offset fPos = positionInRenderedText(tester, 'abc def ghi', 6); + // Select from 'd' until 'f'. + final TestGesture firstGesture = + await tester.startGesture(dPos, kind: PointerDeviceKind.mouse); + addTearDown(firstGesture.removePointer); + await tester.pump(); + await firstGesture.moveTo(fPos); + await firstGesture.up(); + await tester.pump(); + + expect(selectableText, 'abc def ghi'); + expect(selectedText, 'de'); + + // Find the positions before character 'j' and 'o'. + final Offset jPos = positionInRenderedText(tester, 'jkl opq', 0); + final Offset oPos = positionInRenderedText(tester, 'jkl opq', 4); + // Select from 'j' until 'o'. + final TestGesture secondGesture = + await tester.startGesture(jPos, kind: PointerDeviceKind.mouse); + addTearDown(secondGesture.removePointer); + await tester.pump(); + await secondGesture.moveTo(oPos); + await secondGesture.up(); + await tester.pump(); + + expect(selectableText, 'jkl opq'); + expect(selectedText, 'jkl '); + }, + ); }); group('Strikethrough', () { diff --git a/packages/flutter_markdown/test/utils.dart b/packages/flutter_markdown/test/utils.dart index 2d1a4ff27de..fa635f27c70 100644 --- a/packages/flutter_markdown/test/utils.dart +++ b/packages/flutter_markdown/test/utils.dart @@ -5,8 +5,10 @@ import 'dart:convert'; import 'dart:io' as io; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,6 +27,50 @@ Iterable selfAndDescendantWidgetsOf(Finder start, WidgetTester tester) { ]; } +// Returns the RenderEditable displaying the given text. +RenderEditable findRenderEditableWithText(WidgetTester tester, String text) { + final Iterable roots = + tester.renderObjectList(find.byType(EditableText)); + expect(roots, isNotEmpty); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable && child.plainText == text) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + for (final RenderObject root in roots) { + root.visitChildren(recursiveFinder); + } + + expect(renderEditable, isNotNull); + return renderEditable; +} + +// Returns the [textOffset] position in rendered [text]. +Offset positionInRenderedText( + WidgetTester tester, String text, int textOffset) { + final RenderEditable renderEditable = + findRenderEditableWithText(tester, text); + final Iterable textOffsetPoints = + renderEditable.getEndpointsForSelection( + TextSelection.collapsed(offset: textOffset), + ); + // Map the points to global positions. + final List endpoints = + textOffsetPoints.map((TextSelectionPoint point) { + return TextSelectionPoint( + renderEditable.localToGlobal(point.point), + point.direction, + ); + }).toList(); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); +} + void expectWidgetTypes(Iterable widgets, List expected) { final List actual = widgets.map((Widget w) => w.runtimeType).toList(); expect(actual, expected);