From a54e8df89e33a6727ae5bbfcf8c7e4ded759e41b Mon Sep 17 00:00:00 2001 From: leiatcornell Date: Tue, 20 Feb 2024 16:03:22 -0800 Subject: [PATCH 1/5] Add MarkdownOnSelectionChangedCallback --- packages/flutter_markdown/lib/src/widget.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 3feee6b70f5..b23f932ec51 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -11,6 +11,11 @@ import 'package:markdown/markdown.dart' as md; import '../flutter_markdown.dart'; import '_functions_io.dart' if (dart.library.html) '_functions_web.dart'; +/// TBD +/// +/// TBD +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 From 0645bdd93278ad087896ed3d0844a6b735dbccb5 Mon Sep 17 00:00:00 2001 From: leiatcornell Date: Tue, 20 Feb 2024 20:21:36 -0800 Subject: [PATCH 2/5] Add actual logic and test --- .../flutter_markdown/lib/src/builder.dart | 7 +++ packages/flutter_markdown/lib/src/widget.dart | 19 ++++++- packages/flutter_markdown/test/text_test.dart | 57 +++++++++++++++++++ packages/flutter_markdown/test/utils.dart | 46 +++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart index 5d3cb10a8e4..e2a4dbd4fde 100644 --- a/packages/flutter_markdown/lib/src/builder.dart +++ b/packages/flutter_markdown/lib/src/builder.dart @@ -115,6 +115,7 @@ class MarkdownBuilder implements md.NodeVisitor { required this.paddingBuilders, required this.listItemCrossAxisAlignment, this.fitContent = false, + this.onSelectionChanged, this.onTapText, this.softLineBreak = false, }); @@ -158,6 +159,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; @@ -869,6 +873,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 b23f932ec51..0bccc1e8a9a 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -11,11 +11,17 @@ import 'package:markdown/markdown.dart' as md; import '../flutter_markdown.dart'; import '_functions_io.dart' if (dart.library.html) '_functions_web.dart'; -/// TBD + +/// 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. /// -/// TBD +/// Used by [MarkdownWidget.onSelectionChanged] typedef MarkdownOnSelectionChangedCallback = void Function( - String text, TextSelection selection, SelectionChangedCause? cause); + 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 @@ -178,6 +184,7 @@ abstract class MarkdownWidget extends StatefulWidget { this.styleSheet, this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material, this.syntaxHighlighter, + this.onSelectionChanged, this.onTapLink, this.onTapText, this.imageDirectory, @@ -221,6 +228,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; @@ -358,6 +368,7 @@ class _MarkdownWidgetState extends State paddingBuilders: widget.paddingBuilders, fitContent: widget.fitContent, listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment, + onSelectionChanged: widget.onSelectionChanged, onTapText: widget.onTapText, softLineBreak: widget.softLineBreak, ); @@ -420,6 +431,7 @@ class MarkdownBody extends MarkdownWidget { super.styleSheet, super.styleSheetTheme = null, super.syntaxHighlighter, + super.onSelectionChanged, super.onTapLink, super.onTapText, super.imageDirectory, @@ -474,6 +486,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/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); From f97b50aedd2718349178058a0debc9a1242280fd Mon Sep 17 00:00:00 2001 From: leiatcornell Date: Tue, 20 Feb 2024 20:32:43 -0800 Subject: [PATCH 3/5] Update README and CHANGELOG --- packages/flutter_markdown/CHANGELOG.md | 5 +++++ packages/flutter_markdown/README.md | 9 +++++++++ packages/flutter_markdown/pubspec.yaml | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md index 9d901df3fa7..d2bd99ff4ca 100644 --- a/packages/flutter_markdown/CHANGELOG.md +++ b/packages/flutter_markdown/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.6.21 + +* Adds `onSelectionChanged` to the constructors of `Markdown` and `MarkdownBody`. +* Documents the selection capabilities in README. + ## 0.6.20 * Adds `textScaler` to `MarkdownStyleSheet`, and deprecates `textScaleFactor`. 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/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml index 0a3c89d24e7..731b3e7f74e 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.20 +version: 0.6.21 environment: sdk: ^3.2.0 From e2fddf7d1cd0cc04f850f95fefb0015ce56a8b11 Mon Sep 17 00:00:00 2001 From: Tarrin Neal Date: Mon, 18 Mar 2024 08:53:31 -0700 Subject: [PATCH 4/5] comment style --- packages/flutter_markdown/lib/src/widget.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart index 0bccc1e8a9a..8edc949242e 100644 --- a/packages/flutter_markdown/lib/src/widget.dart +++ b/packages/flutter_markdown/lib/src/widget.dart @@ -14,6 +14,7 @@ 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 From 82343e1d1fedc83cc473fba6a6e4ea39f23c516e Mon Sep 17 00:00:00 2001 From: Tarrin Neal Date: Mon, 18 Mar 2024 08:56:19 -0700 Subject: [PATCH 5/5] Update pubspec.yaml --- packages/flutter_markdown/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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