diff --git a/CHANGELOG.md b/CHANGELOG.md index e366034..b956487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/src/builder.dart b/lib/src/builder.dart index ce1bdc4..3e965bd 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -114,6 +114,7 @@ class MarkdownBuilder implements md.NodeVisitor { this.fitContent = false, this.onSelectionChanged, this.onTapText, + this.contextMenuBuilder, this.softLineBreak = false, }); @@ -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. /// @@ -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 { diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 1bb99aa..5f2dfec 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -213,6 +213,7 @@ abstract class MarkdownWidget extends StatefulWidget { this.onSelectionChanged, this.onTapLink, this.onTapText, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.imageDirectory, this.blockSyntaxes, this.inlineSyntaxes, @@ -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; @@ -391,6 +407,7 @@ class _MarkdownWidgetState extends State implements MarkdownBuil listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment, onSelectionChanged: widget.onSelectionChanged, onTapText: widget.onTapText, + contextMenuBuilder: widget.contextMenuBuilder, softLineBreak: widget.softLineBreak, ); @@ -454,6 +471,7 @@ class MarkdownBody extends MarkdownWidget { super.onSelectionChanged, super.onTapLink, super.onTapText, + super.contextMenuBuilder, super.imageDirectory, super.blockSyntaxes, super.inlineSyntaxes, @@ -508,6 +526,7 @@ class Markdown extends MarkdownWidget { super.onSelectionChanged, super.onTapLink, super.onTapText, + super.contextMenuBuilder, super.imageDirectory, super.blockSyntaxes, super.inlineSyntaxes, diff --git a/pubspec.yaml b/pubspec.yaml index 26db666..9fd37de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/all.dart b/test/all.dart index 28338db..60d8a6d 100644 --- a/test/all.dart +++ b/test/all.dart @@ -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; @@ -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(); diff --git a/test/context_menu_builder_test.dart b/test/context_menu_builder_test.dart new file mode 100644 index 0000000..c95c2de --- /dev/null +++ b/test/context_menu_builder_test.dart @@ -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(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(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(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(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(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(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'); + }); + }); +}