Skip to content
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 1.0.10

* Added a `contextMenuBuilder` parameter to `Markdown` and `MarkdownBody`, letting callers customise or suppress (pass `null`) the text-selection toolbar shown when `selectable` is enabled. Defaults to the platform-adaptive Flutter selection toolbar, so existing behaviour is unchanged (#134)
* `onSelectionChanged` now reports the full selectable plain text via `TextSpan.toPlainText()`. Previously, paragraphs containing rich/nested inline spans (bold, italic, code, links) reported the top-level `TextSpan.text`, which was `null` for multi-child spans; callers now receive the complete text so `TextSelection.textInside` works as expected (#134)

**Thanks to our contributors:**
* [@momadvisor](https://github.com/momadvisor) for the `contextMenuBuilder` parameter and plain-text selection fix (#134)

## 1.0.9

* Fixed custom inline `builders` (e.g. `a`) duplicating trailing text when the element had multiple children, such as link text containing a `_` or `*` delimiter that the parser splits into separate text nodes (#132)
Expand Down
9 changes: 8 additions & 1 deletion lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class MarkdownBuilder implements md.NodeVisitor {
this.fitContent = false,
this.onSelectionChanged,
this.onTapText,
this.contextMenuBuilder,
this.softLineBreak = false,
});

Expand Down Expand Up @@ -162,6 +163,11 @@ class MarkdownBuilder implements md.NodeVisitor {
/// Default tap handler used when [selectable] is set to true
final VoidCallback? onTapText;

/// Builds the text selection toolbar when [selectable] is set to true.
///
/// Set this to null to suppress the text selection context menu.
final EditableTextContextMenuBuilder? contextMenuBuilder;

/// The soft line break is used to identify the spaces at the end of aline of
/// text and the leading spaces in the immediately following the line of text.
///
Expand Down Expand Up @@ -1012,9 +1018,10 @@ class MarkdownBuilder implements md.NodeVisitor {
strutStyle: strutStyle,
onSelectionChanged: onSelectionChanged != null
? (TextSelection selection, SelectionChangedCause? cause) =>
onSelectionChanged!(text.text, selection, cause)
onSelectionChanged!(text.toPlainText(), selection, cause)
: null,
onTap: onTapText,
contextMenuBuilder: contextMenuBuilder,
key: k,
);
} else {
Expand Down
19 changes: 19 additions & 0 deletions lib/src/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ abstract class MarkdownWidget extends StatefulWidget {
this.onSelectionChanged,
this.onTapLink,
this.onTapText,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.imageDirectory,
this.blockSyntaxes,
this.inlineSyntaxes,
Expand Down Expand Up @@ -259,6 +260,21 @@ abstract class MarkdownWidget extends StatefulWidget {
/// Default tap handler used when [selectable] is set to true
final VoidCallback? onTapText;

/// Builds the text selection toolbar when [selectable] is set to true.
///
/// Defaults to the platform-adaptive Flutter text selection toolbar. Set this
/// to null to suppress the text selection context menu.
final EditableTextContextMenuBuilder? contextMenuBuilder;

static Widget _defaultContextMenuBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}

/// The base directory holding images referenced by Img tags with local or network file paths.
final String? imageDirectory;

Expand Down Expand Up @@ -391,6 +407,7 @@ class _MarkdownWidgetState extends State<MarkdownWidget> implements MarkdownBuil
listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment,
onSelectionChanged: widget.onSelectionChanged,
onTapText: widget.onTapText,
contextMenuBuilder: widget.contextMenuBuilder,
softLineBreak: widget.softLineBreak,
);

Expand Down Expand Up @@ -454,6 +471,7 @@ class MarkdownBody extends MarkdownWidget {
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.contextMenuBuilder,
super.imageDirectory,
super.blockSyntaxes,
super.inlineSyntaxes,
Expand Down Expand Up @@ -508,6 +526,7 @@ class Markdown extends MarkdownWidget {
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.contextMenuBuilder,
super.imageDirectory,
super.blockSyntaxes,
super.inlineSyntaxes,
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: A Markdown renderer for Flutter. Create rich text output,
repository: https://github.com/foresightmobile/flutter_markdown_plus
issue_tracker: https://github.com/foresightmobile/flutter_markdown_plus/issues
documentation: https://github.com/foresightmobile/flutter_markdown_plus#readme
version: 1.0.9
version: 1.0.10

environment:
sdk: ^3.4.0
Expand Down
2 changes: 2 additions & 0 deletions test/all.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

import 'blockquote_test.dart' as blockquote_test;
import 'context_menu_builder_test.dart' as context_menu_builder_test;
import 'custom_syntax_test.dart' as custome_syntax_test;
import 'emphasis_test.dart' as emphasis_test;
import 'footnote_test.dart' as footnote_test;
Expand All @@ -24,6 +25,7 @@ import 'uri_test.dart' as uri_test;

void main() {
blockquote_test.defineTests();
context_menu_builder_test.defineTests();
custome_syntax_test.defineTests();
emphasis_test.defineTests();
footnote_test.defineTests();
Expand Down
154 changes: 154 additions & 0 deletions test/context_menu_builder_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';

void main() => defineTests();

void defineTests() {
group('contextMenuBuilder', () {
testWidgets('uses the default context menu builder when omitted', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: MarkdownBody(data: 'Selectable text', selectable: true)));

final SelectableText text = tester.widget<SelectableText>(find.byType(SelectableText));

expect(text.contextMenuBuilder, isNotNull);
});

testWidgets('can suppress the selectable text context menu', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: MarkdownBody(data: 'Selectable text', selectable: true, contextMenuBuilder: null)),
);

final SelectableText text = tester.widget<SelectableText>(find.byType(SelectableText));

expect(text.contextMenuBuilder, isNull);
});

testWidgets('passes a custom context menu builder to selectable text', (WidgetTester tester) async {
Widget customContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return const SizedBox.shrink();
}

await tester.pumpWidget(
MaterialApp(
home: MarkdownBody(data: 'Selectable text', selectable: true, contextMenuBuilder: customContextMenuBuilder),
),
);

final SelectableText text = tester.widget<SelectableText>(find.byType(SelectableText));

expect(text.contextMenuBuilder, same(customContextMenuBuilder));
});

testWidgets('selection callback receives rich text as plain text', (WidgetTester tester) async {
String? selectedText;

await tester.pumpWidget(
MaterialApp(
home: MarkdownBody(
data: 'Selectable **rich** text',
selectable: true,
onSelectionChanged: (String? text, TextSelection selection, SelectionChangedCause? cause) {
selectedText = text;
},
),
),
);

final SelectableText text = tester.widget<SelectableText>(find.byType(SelectableText));
text.onSelectionChanged?.call(const TextSelection(baseOffset: 0, extentOffset: 10), SelectionChangedCause.drag);

expect(selectedText, 'Selectable rich text');
});
});

// These tests pin down the behaviour change introduced with contextMenuBuilder:
// onSelectionChanged now reports `TextSpan.toPlainText()` rather than the
// top-level `TextSpan.text`. For any paragraph containing more than one inline
// span (bold, italic, code, links, ...) the parent span is built as
// `TextSpan(children: [...])` with a null `.text`, so the previous behaviour
// handed callers `null`. The full plain text is what downstream selection UIs
// actually need.
group('onSelectionChanged plain text', () {
// Invoke the SelectableText's own onSelectionChanged the way the framework
// would, capturing what the MarkdownBody callback receives.
Future<({String? reported, TextSpan? span})> selectAll(
WidgetTester tester,
String data,
) async {
String? reported;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MarkdownBody(
data: data,
selectable: true,
onSelectionChanged: (String? text, TextSelection selection, SelectionChangedCause? cause) {
reported = text;
},
),
),
),
);

final SelectableText widget = tester.widget<SelectableText>(find.byType(SelectableText));
final TextSpan? span = widget.textSpan;
final int length = span?.toPlainText().length ?? 0;
widget.onSelectionChanged?.call(
TextSelection(baseOffset: 0, extentOffset: length),
SelectionChangedCause.drag,
);
return (reported: reported, span: span);
}

testWidgets('plain-only paragraph is reported unchanged', (WidgetTester tester) async {
final result = await selectAll(tester, 'Just some plain text');
expect(result.reported, 'Just some plain text');
});

testWidgets('multiple mixed inline styles are fully concatenated', (WidgetTester tester) async {
final result = await selectAll(tester, '**bold** _italic_ and `code`');

// Regression guard: the parent span has no top-level text (it is built
// from children), so the pre-change `text.text` path would have been null.
expect(result.span?.text, isNull);
expect(result.reported, 'bold italic and code');
});

testWidgets('link text is included in the reported plain text', (WidgetTester tester) async {
final result = await selectAll(tester, 'Visit [our site](https://example.com) now');

expect(result.span?.text, isNull);
expect(result.reported, 'Visit our site now');
});

testWidgets('reported plain text is usable with TextSelection.textInside', (WidgetTester tester) async {
String? reported;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MarkdownBody(
data: 'alpha **beta** gamma',
selectable: true,
onSelectionChanged: (String? text, TextSelection selection, SelectionChangedCause? cause) {
reported = text;
},
),
),
),
);

final SelectableText widget = tester.widget<SelectableText>(find.byType(SelectableText));
// Select "beta" within "alpha beta gamma".
const TextSelection selection = TextSelection(baseOffset: 6, extentOffset: 10);
widget.onSelectionChanged?.call(selection, SelectionChangedCause.drag);

expect(reported, 'alpha beta gamma');
expect(selection.textInside(reported!), 'beta');
});
});
}
Loading