Skip to content

[flutter_markdown] Adds onSelectionChanged in Markdown #6169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 18, 2024
Merged
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
4 changes: 4 additions & 0 deletions packages/flutter_markdown/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
9 changes: 9 additions & 0 deletions packages/flutter_markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/flutter_markdown/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
);
Expand Down
19 changes: 19 additions & 0 deletions packages/flutter_markdown/lib/src/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -353,6 +369,7 @@ class _MarkdownWidgetState extends State<MarkdownWidget>
paddingBuilders: widget.paddingBuilders,
fitContent: widget.fitContent,
listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment,
onSelectionChanged: widget.onSelectionChanged,
onTapText: widget.onTapText,
softLineBreak: widget.softLineBreak,
);
Expand Down Expand Up @@ -415,6 +432,7 @@ class MarkdownBody extends MarkdownWidget {
super.styleSheet,
super.styleSheetTheme = null,
super.syntaxHighlighter,
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.imageDirectory,
Expand Down Expand Up @@ -469,6 +487,7 @@ class Markdown extends MarkdownWidget {
super.styleSheet,
super.styleSheetTheme = null,
super.syntaxHighlighter,
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.imageDirectory,
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter_markdown/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/flutter_markdown/test/text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () {
Expand Down
46 changes: 46 additions & 0 deletions packages/flutter_markdown/test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,6 +27,50 @@ Iterable<Widget> selfAndDescendantWidgetsOf(Finder start, WidgetTester tester) {
];
}

// Returns the RenderEditable displaying the given text.
RenderEditable findRenderEditableWithText(WidgetTester tester, String text) {
final Iterable<RenderObject> 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<TextSelectionPoint> textOffsetPoints =
renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: textOffset),
);
// Map the points to global positions.
final List<TextSelectionPoint> endpoints =
textOffsetPoints.map<TextSelectionPoint>((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<Widget> widgets, List<Type> expected) {
final List<Type> actual = widgets.map((Widget w) => w.runtimeType).toList();
expect(actual, expected);
Expand Down