From 82a667336c1b37ed30acc452424240bbd6b580df Mon Sep 17 00:00:00 2001 From: Ellet Date: Thu, 27 Mar 2025 15:21:21 +0300 Subject: [PATCH 1/6] fix: improve customLinkPrefixes doc comment, allows to override link validation in the toolbar, allows to insert mailto and other links by default --- example/lib/main.dart | 8 ++ lib/src/common/utils/link_validator.dart | 98 +++++++++++++++++++ lib/src/editor/config/editor_config.dart | 13 ++- lib/src/editor/widgets/link.dart | 6 ++ lib/src/editor/widgets/text/text_line.dart | 16 +-- lib/src/rules/insert.dart | 65 ++++++------ .../toolbar/buttons/link_style2_button.dart | 17 ++-- .../toolbar/buttons/link_style_button.dart | 30 ++++-- .../config/buttons/link_style_options.dart | 16 +++ test/common/utils/link_validator_test.dart | 49 ++++++++++ test/rules/insert_test.dart | 31 +----- 11 files changed, 259 insertions(+), 90 deletions(-) create mode 100644 lib/src/common/utils/link_validator.dart create mode 100644 test/common/utils/link_validator_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 715dc81c2..f98bbd2dc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -135,6 +135,14 @@ class _HomePageState extends State { } }, ), + linkStyle: QuillToolbarLinkStyleButtonOptions( + validateLink: (link) { + // Treats all links as valid. When launching the URL, + // `https://` is prefixed if the link is incomplete (e.g., `google.com` → `https://google.com`) + // however this happens only within the editor. + return true; + }, + ), ), ), ), diff --git a/lib/src/common/utils/link_validator.dart b/lib/src/common/utils/link_validator.dart new file mode 100644 index 000000000..36f9f09d6 --- /dev/null +++ b/lib/src/common/utils/link_validator.dart @@ -0,0 +1,98 @@ +@internal +library; + +import 'package:meta/meta.dart'; + +/// {@template link_validation_callback} +/// A callback to validate whether the [link] is valid. +/// +/// The [link] is passed to the callback, which should return `true` if valid, +/// or `false` otherwise. +/// +/// Example: +/// +/// ```dart +/// validateLink: (link) { +/// if (link.startsWith('ws')) { +/// return true; // WebSocket links are considered valid +/// } +/// final regex = RegExp(r'^(http|https)://[a-zA-Z0-9.-]+'); +/// return regex.hasMatch(link); +/// } +/// ``` +/// +/// Return `null` to fallback to the default handling: +/// +/// ```dart +/// validateLink: (link) { +/// if (link.startsWith('custom')) { +/// return true; +/// } +/// return null; +/// } +/// ``` +/// +/// Another example to allow inserting any link: +/// +/// ```dart +/// validateLink: (link) { +/// // Treats all links as valid. When launching the URL, +/// // `https://` is prefixed if the link is incomplete (e.g., `google.com` → `https://google.com`) +/// // however this happens only within the editor level and the +/// // the URL will be stored as: +/// // {insert: ..., attributes: {link: google.com}} +/// return true; +/// } +/// ``` +/// +/// NOTE: The link will always be considered invalid if empty, and this callback will +/// not be called. +/// +/// {@endtemplate} +typedef LinkValidationCallback = bool? Function(String link); + +abstract final class LinkValidator { + static const linkPrefixes = [ + 'mailto:', // email + 'tel:', // telephone + 'sms:', // SMS + 'callto:', + 'wtai:', + 'market:', + 'geopoint:', + 'ymsgr:', + 'msnim:', + 'gtalk:', // Google Talk + 'skype:', + 'sip:', // Lync + 'whatsapp:', + 'http://', + 'https://' + ]; + + static bool validate( + String link, { + LinkValidationCallback? customValidateLink, + RegExp? legacyRegex, + List? legacyAddationalLinkPrefixes, + }) { + if (link.trim().isEmpty) { + return false; + } + if (customValidateLink != null) { + final isValid = customValidateLink(link); + if (isValid != null) { + return isValid; + } + } + // Implemented for backward compatibility, clients should use validateLink instead. + // ignore: deprecated_member_use_from_same_package + final legacyRegexp = legacyRegex; + if (legacyRegexp?.hasMatch(link) == true) { + return true; + } + // Implemented for backward compatibility, clients should use validateLink instead. + return (linkPrefixes + (legacyAddationalLinkPrefixes ?? [])) + .any((prefix) => link.startsWith(prefix)); + } +} diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index c51edafaf..a60f1b5b6 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart' show experimental; +import '../../../internal.dart' show AutoFormatMultipleLinksRule; import '../../document/nodes/node.dart'; import '../../toolbar/theme/quill_dialog_theme.dart'; import '../embed/embed_editor_builder.dart'; @@ -13,7 +14,7 @@ import '../raw_editor/config/raw_editor_config.dart'; import '../raw_editor/raw_editor.dart'; import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; -import '../widgets/link.dart'; +import '../widgets/link.dart' hide linkPrefixes; import '../widgets/text/magnifier.dart'; import '../widgets/text/utils/text_block_utils.dart'; import 'search_config.dart'; @@ -416,10 +417,14 @@ class QuillEditorConfig { final bool detectWordBoundary; - /// Additional list if links prefixes, which must not be prepended - /// with "https://" when [LinkMenuAction.launch] happened + /// Link prefixes that are addations to [linkPrefixes], which are used + /// on link launch [LinkMenuAction.launch] to check whether a link is valid. /// - /// Useful for deep-links + /// If a link is not valid and link launch is requested, + /// the editor will append `https://` as prefix to the link. + /// + /// This is used to tapping links within the editor, and not the toolbar or + /// [AutoFormatMultipleLinksRule]. final List customLinkPrefixes; /// Configures the dialog theme. diff --git a/lib/src/editor/widgets/link.dart b/lib/src/editor/widgets/link.dart index 9ef819af4..6338f4a8d 100644 --- a/lib/src/editor/widgets/link.dart +++ b/lib/src/editor/widgets/link.dart @@ -1,12 +1,18 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; import '../../controller/quill_controller.dart'; import '../../document/attribute.dart'; import '../../document/nodes/node.dart'; import '../../l10n/extensions/localizations_ext.dart'; +@Deprecated( + 'Moved to LinkValidator.linkPrefixes but no longer available with the public' + 'API. The item `http` has been removed and replaced with `http://` and `https://`.', +) +@internal const linkPrefixes = [ 'mailto:', // email 'tel:', // telephone diff --git a/lib/src/editor/widgets/text/text_line.dart b/lib/src/editor/widgets/text/text_line.dart index 32e03496b..6a1632b82 100644 --- a/lib/src/editor/widgets/text/text_line.dart +++ b/lib/src/editor/widgets/text/text_line.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../flutter_quill.dart'; import '../../../common/utils/color.dart'; import '../../../common/utils/font.dart'; +import '../../../common/utils/link_validator.dart'; import '../../../common/utils/platform.dart'; import '../../../document/nodes/container.dart' as container_node; import '../../../document/nodes/leaf.dart' as leaf; @@ -671,19 +672,20 @@ class _TextLineState extends State { _tapLink(link); } - void _tapLink(String? link) { + void _tapLink(final String? inputLink) { + var link = inputLink?.trim(); if (link == null) { return; } - var launchUrl = widget.onLaunchUrl; - launchUrl ??= _launchUrl; - - link = link.trim(); - if (!(widget.customLinkPrefixes + linkPrefixes) - .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { + final isValidLink = LinkValidator.validate(link, + legacyAddationalLinkPrefixes: widget.customLinkPrefixes); + if (!isValidLink) { link = 'https://$link'; } + + // TODO: Maybe we should refactor onLaunchUrl or add a new API to guve full control of the launch? + final launchUrl = widget.onLaunchUrl ?? _launchUrl; launchUrl(link); } diff --git a/lib/src/rules/insert.dart b/lib/src/rules/insert.dart index 851c9b806..124520460 100644 --- a/lib/src/rules/insert.dart +++ b/lib/src/rules/insert.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart' show immutable; +import 'package:meta/meta.dart'; import '../../quill_delta.dart'; import '../common/extensions/uri_ext.dart'; @@ -362,24 +362,38 @@ class AutoFormatMultipleLinksRule extends InsertRule { // https://example.net/ // URL generator tool (https://www.randomlists.com/urls) is used. - static const _oneLineLinkPattern = - r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#].*)?$'; - static const _detectLinkPattern = - r'https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#][^\s]*)?'; - - /// It requires a valid link in one link - RegExp get oneLineLinkRegExp => RegExp( - _oneLineLinkPattern, + /// A regular expression to match a single-line URL + @internal + static RegExp get singleLineUrlRegExp => RegExp( + r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#].*)?$', caseSensitive: false, ); - /// It detect if there is a link in the text whatever if it in the middle etc - // Used to solve bug https://github.com/singerdmx/flutter-quill/issues/1432 - RegExp get detectLinkRegExp => RegExp( - _detectLinkPattern, + /// A regular expression to detect a URL anywhere in the text, even if it's in the middle of other content. + /// Used to resolve bug https://github.com/singerdmx/flutter-quill/issues/1432 + @internal + static RegExp get urlInTextRegExp => RegExp( + r'https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#][^\s]*)?', caseSensitive: false, ); - RegExp get linkRegExp => oneLineLinkRegExp; + + @Deprecated( + 'Deprecated and will be removed in future-releasese as this is not the place to store regex.\n' + 'Please use a custom regex instead or use AutoFormatMultipleLinksRule.singleLineUrlRegExp which is an internal API.', + ) + RegExp get oneLineLinkRegExp => singleLineUrlRegExp; + + @Deprecated( + 'Deprecated and will be removed in future-releasese as this is not the place to store regex.\n' + 'Please use a custom regex instead or use AutoFormatMultipleLinksRule.urlInTextRegExp which is an internal API.', + ) + RegExp get detectLinkRegExp => urlInTextRegExp; + + @Deprecated( + 'No longer used and will be silently ignored. Please use custom regex ' + 'or use AutoFormatMultipleLinksRule.singleLineUrlRegExp which is an internal API.', + ) + RegExp get linkRegExp => singleLineUrlRegExp; @override Delta? applyRule( @@ -388,6 +402,8 @@ class AutoFormatMultipleLinksRule extends InsertRule { int? len, Object? data, Attribute? attribute, + @Deprecated( + 'No longer used and will be silently ignored and removed in future releases.') Object? extraData, }) { // Only format when inserting text. @@ -423,27 +439,8 @@ class AutoFormatMultipleLinksRule extends InsertRule { // Build the segment of affected words. final affectedWords = '$leftWordPart$data$rightWordPart'; - var usedRegExp = detectLinkRegExp; - final alternativeLinkRegExp = extraData; - if (alternativeLinkRegExp != null) { - try { - if (alternativeLinkRegExp is! String) { - throw ArgumentError.value( - alternativeLinkRegExp, - 'alternativeLinkRegExp', - '`alternativeLinkRegExp` should be of type String', - ); - } - final regPattern = alternativeLinkRegExp; - usedRegExp = RegExp( - regPattern, - caseSensitive: false, - ); - } catch (_) {} - } - // Check for URL pattern. - final matches = usedRegExp.allMatches(affectedWords); + final matches = urlInTextRegExp.allMatches(affectedWords); // If there are no matches, do not apply any format. if (matches.isEmpty) return null; diff --git a/lib/src/toolbar/buttons/link_style2_button.dart b/lib/src/toolbar/buttons/link_style2_button.dart index 688d5d909..5b18623e8 100644 --- a/lib/src/toolbar/buttons/link_style2_button.dart +++ b/lib/src/toolbar/buttons/link_style2_button.dart @@ -2,11 +2,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/link.dart'; +import '../../common/utils/link_validator.dart'; import '../../common/utils/widgets.dart'; import '../../editor/widgets/link.dart'; import '../../l10n/extensions/localizations_ext.dart'; -import '../../rules/insert.dart'; import '../base_button/base_value_button.dart'; import '../config/simple_toolbar_config.dart'; @@ -359,15 +359,14 @@ class _LinkStyleDialogState extends State { bool _canPress() => _validateLink(_link) == null; - String? _validateLink(String? value) { - if ((value?.isEmpty ?? false) || - !const AutoFormatMultipleLinksRule() - .oneLineLinkRegExp - .hasMatch(value!)) { - return widget.validationMessage ?? 'That is not a valid URL'; - } + String? _validateLink(final String? value) { + final input = value ?? ''; - return null; + final errorMessage = LinkValidator.validate(input) + ? null + // TODO: Translate + : (widget.validationMessage ?? 'That is not a valid URL'); + return errorMessage; } void _applyLink() => diff --git a/lib/src/toolbar/buttons/link_style_button.dart b/lib/src/toolbar/buttons/link_style_button.dart index 07c8ccce6..08f92cf2c 100644 --- a/lib/src/toolbar/buttons/link_style_button.dart +++ b/lib/src/toolbar/buttons/link_style_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../common/utils/link_validator.dart'; import '../../editor/widgets/link.dart'; import '../../l10n/extensions/localizations_ext.dart'; import '../../rules/insert.dart'; @@ -106,10 +107,12 @@ class QuillToolbarLinkStyleButtonState context: context, builder: (_) { return _LinkDialog( + validateLink: options.validateLink, + // ignore: deprecated_member_use_from_same_package + legacyLinkRegExp: options.linkRegExp, dialogTheme: options.dialogTheme, text: initialTextLink.text, link: initialTextLink.link, - linkRegExp: options.linkRegExp, action: options.linkDialogAction, ); }, @@ -122,17 +125,19 @@ class QuillToolbarLinkStyleButtonState class _LinkDialog extends StatefulWidget { const _LinkDialog({ + required this.validateLink, this.dialogTheme, this.link, this.text, - this.linkRegExp, + this.legacyLinkRegExp, this.action, }); final QuillDialogTheme? dialogTheme; final String? link; final String? text; - final RegExp? linkRegExp; + final RegExp? legacyLinkRegExp; + final LinkValidationCallback? validateLink; final LinkDialogAction? action; @override @@ -143,9 +148,11 @@ class _LinkDialogState extends State<_LinkDialog> { late String _link; late String _text; + @Deprecated( + 'Will be removed in future-releases, please migrate to QuillToolbarLinkStyleButtonOptions.validateLink.') RegExp get linkRegExp { - return widget.linkRegExp ?? - const AutoFormatMultipleLinksRule().oneLineLinkRegExp; + return widget.legacyLinkRegExp ?? + AutoFormatMultipleLinksRule.singleLineUrlRegExp; } late TextEditingController _linkController; @@ -242,15 +249,18 @@ class _LinkDialogState extends State<_LinkDialog> { ); } + bool get _isLinkValid => LinkValidator.validate( + _link, + customValidateLink: widget.validateLink, + // Implemented for backward compatibility, clients should use validateLink instead. + legacyRegex: widget.legacyLinkRegExp, + ); + bool _canPress() { if (_text.isEmpty || _link.isEmpty) { return false; } - if (!linkRegExp.hasMatch(_link)) { - return false; - } - - return true; + return _isLinkValid; } void _linkChanged(String value) { diff --git a/lib/src/toolbar/config/buttons/link_style_options.dart b/lib/src/toolbar/config/buttons/link_style_options.dart index 01d2c0628..d6f2c7630 100644 --- a/lib/src/toolbar/config/buttons/link_style_options.dart +++ b/lib/src/toolbar/config/buttons/link_style_options.dart @@ -1,3 +1,7 @@ +/// @docImport '../../../rules/insert.dart' show AutoFormatMultipleLinksRule; +library; + +import '../../../common/utils/link_validator.dart'; import '../../simple_toolbar.dart'; import '../../structs/link_dialog_action.dart'; import '../../theme/quill_dialog_theme.dart'; @@ -18,6 +22,7 @@ class QuillToolbarLinkStyleButtonOptions extends QuillToolbarBaseButtonOptions< this.dialogTheme, this.linkRegExp, this.linkDialogAction, + this.validateLink, super.iconSize, super.iconButtonFactor, super.iconData, @@ -28,6 +33,17 @@ class QuillToolbarLinkStyleButtonOptions extends QuillToolbarBaseButtonOptions< }); final QuillDialogTheme? dialogTheme; + + /// Allows to override the default [AutoFormatMultipleLinksRule.singleLineUrlRegExp]. + /// + /// This has been deprecated in favor of [validateLink] which is more flexible. + @Deprecated('Use validateLink instead') final RegExp? linkRegExp; final LinkDialogAction? linkDialogAction; + + /// {@macro link_validation_callback} + /// + // ignore: deprecated_member_use_from_same_package + /// This callback is preferred over [linkRegExp] when both are set. + final LinkValidationCallback? validateLink; } diff --git a/test/common/utils/link_validator_test.dart b/test/common/utils/link_validator_test.dart new file mode 100644 index 000000000..8835a1cc4 --- /dev/null +++ b/test/common/utils/link_validator_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_quill/src/common/utils/link_validator.dart'; +import 'package:test/test.dart'; + +const validTesingLinks = [ + 'http://google.com', + 'https://www.google.com', + 'http://beginner.example.edu/#act', + 'http://beginner.example.edu#act', + 'https://birth.example.net/beds/ants.php#bait', + 'http://example.com/babies', + 'https://www.example.com/', + 'https://attack.example.edu/?acoustics=blade&bed=bed', + 'https://attack.example.edu?acoustics=blade&bed=bed', + 'http://basketball.example.com/', + 'https://birthday.example.com/birthday', + 'http://www.example.com/', + 'https://example.com/addition/action', + 'http://example.com/', + 'https://bite.example.net/#adjustment', + 'https://bite.example.net#adjustment', + 'http://www.example.net/badge.php?bedroom=anger', + 'https://brass.example.com/?anger=branch&actor=amusement#adjustment', + 'https://brass.example.com?anger=branch&actor=amusement#adjustment', + 'http://www.example.com/?action=birds&brass=apparatus', + 'http://www.example.com?action=birds&brass=apparatus', + 'https://example.net/', + 'mailto:test@example.com', + 'tel:+1234567890', + 'sms:+1234567890', + 'callto:+1234567890', + 'wtai://wp/mc;1234567890', + 'market://details?id=com.example.app', + 'geopoint:37.7749,-122.4194', + 'ymsgr:sendIM?user=testuser', + 'msnim:chat?contact=testuser', + 'gtalk://talk.google.com/talk?jid=testuser@gmail.com', + 'skype:live:testuser?chat', + 'sip:username@domain.com', + 'whatsapp://send?phone=+1234567890', +]; + +void main() { + test('validate correctly', () { + for (final validLink in validTesingLinks) { + expect(LinkValidator.validate(validLink), true, + reason: 'Expected the link `$validLink` to be valid.'); + } + }); +} diff --git a/test/rules/insert_test.dart b/test/rules/insert_test.dart index dfafa2800..cb435a153 100644 --- a/test/rules/insert_test.dart +++ b/test/rules/insert_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_quill/quill_delta.dart'; import 'package:flutter_quill/src/rules/insert.dart'; import 'package:test/test.dart'; +import '../common/utils/link_validator_test.dart'; + void main() { group('PreserveInlineStylesRule', () { const rule = PreserveInlineStylesRule(); @@ -290,41 +292,18 @@ void main() { group('AutoFormatMultipleLinksRule', () { const rule = AutoFormatMultipleLinksRule(); - final validLinks = [ - 'http://google.com', - 'https://www.google.com', - 'http://beginner.example.edu/#act', - 'http://beginner.example.edu#act', - 'https://birth.example.net/beds/ants.php#bait', - 'http://example.com/babies', - 'https://www.example.com/', - 'https://attack.example.edu/?acoustics=blade&bed=bed', - 'https://attack.example.edu?acoustics=blade&bed=bed', - 'http://basketball.example.com/', - 'https://birthday.example.com/birthday', - 'http://www.example.com/', - 'https://example.com/addition/action', - 'http://example.com/', - 'https://bite.example.net/#adjustment', - 'https://bite.example.net#adjustment', - 'http://www.example.net/badge.php?bedroom=anger', - 'https://brass.example.com/?anger=branch&actor=amusement#adjustment', - 'https://brass.example.com?anger=branch&actor=amusement#adjustment', - 'http://www.example.com/?action=birds&brass=apparatus', - 'http://www.example.com?action=birds&brass=apparatus', - 'https://example.net/', - ]; - test('Insert link in text', () { final delta = Delta()..insert('\n'); final document = Document.fromDelta(delta); - for (final link in validLinks) { + for (final link in validTesingLinks) { expect( rule.apply(document, 0, data: link, len: 0), Delta()..insert(link, {'link': link}), ); } }); + + // TODO: Write tests for the bug fix: https://github.com/singerdmx/flutter-quill/issues/1432 }); } From 02d43801f02f9d23c6818f5d18bdc37173fac6e1 Mon Sep 17 00:00:00 2001 From: Ellet Date: Thu, 27 Mar 2025 15:28:19 +0300 Subject: [PATCH 2/6] revert: AutoFormatMultipleLinksRule test aginst the old valid links --- lib/src/editor/widgets/text/text_line.dart | 2 +- test/common/utils/link_validator_test.dart | 4 +-- test/rules/insert_test.dart | 29 +++++++++++++++++++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/src/editor/widgets/text/text_line.dart b/lib/src/editor/widgets/text/text_line.dart index 6a1632b82..840ebc240 100644 --- a/lib/src/editor/widgets/text/text_line.dart +++ b/lib/src/editor/widgets/text/text_line.dart @@ -684,7 +684,7 @@ class _TextLineState extends State { link = 'https://$link'; } - // TODO: Maybe we should refactor onLaunchUrl or add a new API to guve full control of the launch? + // TODO(EchoEllet): Maybe we should refactor onLaunchUrl or add a new API to guve full control of the launch, see #1776? final launchUrl = widget.onLaunchUrl ?? _launchUrl; launchUrl(link); } diff --git a/test/common/utils/link_validator_test.dart b/test/common/utils/link_validator_test.dart index 8835a1cc4..4956a6314 100644 --- a/test/common/utils/link_validator_test.dart +++ b/test/common/utils/link_validator_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_quill/src/common/utils/link_validator.dart'; import 'package:test/test.dart'; -const validTesingLinks = [ +const _validTesingLinks = [ 'http://google.com', 'https://www.google.com', 'http://beginner.example.edu/#act', @@ -41,7 +41,7 @@ const validTesingLinks = [ void main() { test('validate correctly', () { - for (final validLink in validTesingLinks) { + for (final validLink in _validTesingLinks) { expect(LinkValidator.validate(validLink), true, reason: 'Expected the link `$validLink` to be valid.'); } diff --git a/test/rules/insert_test.dart b/test/rules/insert_test.dart index cb435a153..eec8dfcbf 100644 --- a/test/rules/insert_test.dart +++ b/test/rules/insert_test.dart @@ -4,8 +4,6 @@ import 'package:flutter_quill/quill_delta.dart'; import 'package:flutter_quill/src/rules/insert.dart'; import 'package:test/test.dart'; -import '../common/utils/link_validator_test.dart'; - void main() { group('PreserveInlineStylesRule', () { const rule = PreserveInlineStylesRule(); @@ -292,11 +290,36 @@ void main() { group('AutoFormatMultipleLinksRule', () { const rule = AutoFormatMultipleLinksRule(); + final validLinks = [ + 'http://google.com', + 'https://www.google.com', + 'http://beginner.example.edu/#act', + 'http://beginner.example.edu#act', + 'https://birth.example.net/beds/ants.php#bait', + 'http://example.com/babies', + 'https://www.example.com/', + 'https://attack.example.edu/?acoustics=blade&bed=bed', + 'https://attack.example.edu?acoustics=blade&bed=bed', + 'http://basketball.example.com/', + 'https://birthday.example.com/birthday', + 'http://www.example.com/', + 'https://example.com/addition/action', + 'http://example.com/', + 'https://bite.example.net/#adjustment', + 'https://bite.example.net#adjustment', + 'http://www.example.net/badge.php?bedroom=anger', + 'https://brass.example.com/?anger=branch&actor=amusement#adjustment', + 'https://brass.example.com?anger=branch&actor=amusement#adjustment', + 'http://www.example.com/?action=birds&brass=apparatus', + 'http://www.example.com?action=birds&brass=apparatus', + 'https://example.net/', + ]; + test('Insert link in text', () { final delta = Delta()..insert('\n'); final document = Document.fromDelta(delta); - for (final link in validTesingLinks) { + for (final link in validLinks) { expect( rule.apply(document, 0, data: link, len: 0), Delta()..insert(link, {'link': link}), From 18b5a15b60aecaa654adeeefe3f58a8aa88345c8 Mon Sep 17 00:00:00 2001 From: Ellet Date: Thu, 27 Mar 2025 15:44:12 +0300 Subject: [PATCH 3/6] docs(changelog): document the changes with the PR link --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad962dd6..d042aab9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Accept `mailto`, `tel`, `sms`, and other link prefixes by default in the insert link toolbar button [#2525](https://github.com/singerdmx/flutter-quill/pull/2525). +- `validateLink` in `QuillToolbarLinkStyleButtonOptions` to allow overriding the link validation [#2525](https://github.com/singerdmx/flutter-quill/pull/2525). + +### Fixed + +- Improve doc comment of `customLinkPrefixes` in `QuillEditor` [#2525](https://github.com/singerdmx/flutter-quill/pull/2525). + +### Changed + +- Deprecate `linkRegExp` in favor of the new callback `validateLink` [#2525](https://github.com/singerdmx/flutter-quill/pull/2525). + ## [11.3.0] - 2025-04-23 ### Fixed @@ -22,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [11.2.0] - 2025-03-26 -### Added +### Added - Cache for `toPlainText` in `Document` class to avoid unnecessary text computing [#2482](https://github.com/singerdmx/flutter-quill/pull/2482). From 2d93ca27a3490a2a2f8f8391bbcc7703107196df Mon Sep 17 00:00:00 2001 From: Ellet Date: Tue, 15 Apr 2025 22:21:31 +0300 Subject: [PATCH 4/6] chore: minor change to a TODO --- lib/src/editor/widgets/text/text_line.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/editor/widgets/text/text_line.dart b/lib/src/editor/widgets/text/text_line.dart index 840ebc240..ee09dcc49 100644 --- a/lib/src/editor/widgets/text/text_line.dart +++ b/lib/src/editor/widgets/text/text_line.dart @@ -684,7 +684,7 @@ class _TextLineState extends State { link = 'https://$link'; } - // TODO(EchoEllet): Maybe we should refactor onLaunchUrl or add a new API to guve full control of the launch, see #1776? + // TODO(EchoEllet): Refactor onLaunchUrl or add a new API to give full control of the launch? See https://github.com/singerdmx/flutter-quill/issues/1776 final launchUrl = widget.onLaunchUrl ?? _launchUrl; launchUrl(link); } From 5d913767439d57cb85687c66ed6152a7a9fc0174 Mon Sep 17 00:00:00 2001 From: Ellet Date: Fri, 18 Apr 2025 17:09:26 +0300 Subject: [PATCH 5/6] chore: address https://github.com/singerdmx/flutter-quill/pull/2525#discussion_r2017706229 (super nit) --- lib/src/editor/config/editor_config.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/config/editor_config.dart b/lib/src/editor/config/editor_config.dart index a60f1b5b6..f0ae1bc13 100644 --- a/lib/src/editor/config/editor_config.dart +++ b/lib/src/editor/config/editor_config.dart @@ -1,10 +1,12 @@ +/// @docImport '../../rules/insert.dart' show AutoFormatMultipleLinksRule; +library; + import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart' show experimental; -import '../../../internal.dart' show AutoFormatMultipleLinksRule; import '../../document/nodes/node.dart'; import '../../toolbar/theme/quill_dialog_theme.dart'; import '../embed/embed_editor_builder.dart'; From 51f3384793ce0d07327392bd4a521d9a7ec9fc73 Mon Sep 17 00:00:00 2001 From: Ellet Date: Fri, 18 Apr 2025 18:43:45 +0300 Subject: [PATCH 6/6] test: adds unit and widget tests --- .../editor_keyboard_shortcut_actions.dart | 2 +- .../link_dialog.dart} | 152 +++--------------- .../{ => link_style}/link_style2_button.dart | 16 +- .../buttons/link_style/link_style_button.dart | 121 ++++++++++++++ .../toolbar/config/simple_toolbar_config.dart | 4 +- lib/src/toolbar/simple_toolbar.dart | 4 +- test/common/utils/link_validator_test.dart | 56 +++++++ test/common/utils/quill_test_app.dart | 37 ++++- .../buttons/link_style_button_test.dart | 107 ++++++++++++ 9 files changed, 348 insertions(+), 151 deletions(-) rename lib/src/toolbar/buttons/{link_style_button.dart => link_style/link_dialog.dart} (51%) rename lib/src/toolbar/buttons/{ => link_style}/link_style2_button.dart (96%) create mode 100644 lib/src/toolbar/buttons/link_style/link_style_button.dart create mode 100644 test/toolbar/buttons/link_style_button_test.dart diff --git a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart index e112e883e..58ef34b51 100644 --- a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart +++ b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../document/attribute.dart'; import '../../../document/style.dart'; -import '../../../toolbar/buttons/link_style2_button.dart'; +import '../../../toolbar/buttons/link_style/link_style2_button.dart'; import '../../../toolbar/buttons/search/search_dialog.dart'; import '../../editor.dart'; import '../../widgets/link.dart'; diff --git a/lib/src/toolbar/buttons/link_style_button.dart b/lib/src/toolbar/buttons/link_style/link_dialog.dart similarity index 51% rename from lib/src/toolbar/buttons/link_style_button.dart rename to lib/src/toolbar/buttons/link_style/link_dialog.dart index 08f92cf2c..a443793c3 100644 --- a/lib/src/toolbar/buttons/link_style_button.dart +++ b/lib/src/toolbar/buttons/link_style/link_dialog.dart @@ -1,131 +1,21 @@ -import 'package:flutter/material.dart'; - -import '../../common/utils/link_validator.dart'; -import '../../editor/widgets/link.dart'; -import '../../l10n/extensions/localizations_ext.dart'; -import '../../rules/insert.dart'; -import '../base_button/base_value_button.dart'; - -import '../config/buttons/link_style_options.dart'; -import '../structs/link_dialog_action.dart'; -import '../theme/quill_dialog_theme.dart'; -import 'quill_icon_button.dart'; - -typedef QuillToolbarLinkStyleBaseButton = QuillToolbarBaseButton< - QuillToolbarLinkStyleButtonOptions, - QuillToolbarLinkStyleButtonExtraOptions>; - -typedef QuillToolbarLinkStyleBaseButtonState< - W extends QuillToolbarLinkStyleBaseButton> - = QuillToolbarCommonButtonState; - -class QuillToolbarLinkStyleButton extends QuillToolbarLinkStyleBaseButton { - const QuillToolbarLinkStyleButton({ - required super.controller, - super.options = const QuillToolbarLinkStyleButtonOptions(), - - /// Shares common options between all buttons, prefer the [options] - /// over the [baseOptions]. - super.baseOptions, - super.key, - }); - - @override - QuillToolbarLinkStyleButtonState createState() => - QuillToolbarLinkStyleButtonState(); -} - -class QuillToolbarLinkStyleButtonState - extends QuillToolbarLinkStyleBaseButtonState { - @override - String get defaultTooltip => context.loc.insertURL; - - void _didChangeSelection() { - setState(() {}); - } - - @override - void initState() { - super.initState(); - controller.addListener(_didChangeSelection); - } - - @override - void didUpdateWidget(covariant QuillToolbarLinkStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != controller) { - oldWidget.controller.removeListener(_didChangeSelection); - controller.addListener(_didChangeSelection); - } - } - - @override - void dispose() { - super.dispose(); - controller.removeListener(_didChangeSelection); - } - - @override - IconData get defaultIconData => Icons.link; - - @override - Widget build(BuildContext context) { - final isToggled = QuillTextLink.isSelected(controller); - - final childBuilder = this.childBuilder; - if (childBuilder != null) { - return childBuilder( - options, - QuillToolbarLinkStyleButtonExtraOptions( - context: context, - controller: controller, - onPressed: () { - _openLinkDialog(context); - afterButtonPressed?.call(); - }, - ), - ); - } - return QuillToolbarIconButton( - tooltip: tooltip, - icon: Icon( - iconData, - size: iconSize * iconButtonFactor, - ), - isSelected: isToggled, - onPressed: () => _openLinkDialog(context), - afterPressed: afterButtonPressed, - iconTheme: iconTheme, - ); - } +@internal +@visibleForTesting +library; - Future _openLinkDialog(BuildContext context) async { - final initialTextLink = QuillTextLink.prepare(widget.controller); +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; - final textLink = await showDialog( - context: context, - builder: (_) { - return _LinkDialog( - validateLink: options.validateLink, - // ignore: deprecated_member_use_from_same_package - legacyLinkRegExp: options.linkRegExp, - dialogTheme: options.dialogTheme, - text: initialTextLink.text, - link: initialTextLink.link, - action: options.linkDialogAction, - ); - }, - ); - if (textLink != null) { - textLink.submit(widget.controller); - } - } -} +import '../../../common/utils/link_validator.dart'; +import '../../../editor/widgets/link.dart'; +import '../../../l10n/extensions/localizations_ext.dart'; +import '../../../rules/insert.dart'; +import '../../structs/link_dialog_action.dart'; +import '../../theme/quill_dialog_theme.dart'; -class _LinkDialog extends StatefulWidget { - const _LinkDialog({ +class LinkDialog extends StatefulWidget { + const LinkDialog({ required this.validateLink, + super.key, this.dialogTheme, this.link, this.text, @@ -141,10 +31,10 @@ class _LinkDialog extends StatefulWidget { final LinkDialogAction? action; @override - _LinkDialogState createState() => _LinkDialogState(); + LinkDialogState createState() => LinkDialogState(); } -class _LinkDialogState extends State<_LinkDialog> { +class LinkDialogState extends State { late String _link; late String _text; @@ -217,7 +107,7 @@ class _LinkDialogState extends State<_LinkDialog> { autofillHints: const [AutofillHints.url], autocorrect: false, onEditingComplete: () { - if (!_canPress()) { + if (!canPress()) { return; } _applyLink(); @@ -235,13 +125,13 @@ class _LinkDialogState extends State<_LinkDialog> { Widget _okButton() { if (widget.action != null) { return widget.action!.builder( - _canPress(), + canPress(), _applyLink, ); } return TextButton( - onPressed: _canPress() ? _applyLink : null, + onPressed: canPress() ? _applyLink : null, child: Text( context.loc.ok, style: widget.dialogTheme?.buttonTextStyle, @@ -256,7 +146,9 @@ class _LinkDialogState extends State<_LinkDialog> { legacyRegex: widget.legacyLinkRegExp, ); - bool _canPress() { + @visibleForTesting + @internal + bool canPress() { if (_text.isEmpty || _link.isEmpty) { return false; } diff --git a/lib/src/toolbar/buttons/link_style2_button.dart b/lib/src/toolbar/buttons/link_style/link_style2_button.dart similarity index 96% rename from lib/src/toolbar/buttons/link_style2_button.dart rename to lib/src/toolbar/buttons/link_style/link_style2_button.dart index 5b18623e8..27429ec7e 100644 --- a/lib/src/toolbar/buttons/link_style2_button.dart +++ b/lib/src/toolbar/buttons/link_style/link_style2_button.dart @@ -2,17 +2,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/link.dart'; -import '../../common/utils/link_validator.dart'; -import '../../common/utils/widgets.dart'; +import '../../../common/utils/link_validator.dart'; +import '../../../common/utils/widgets.dart'; -import '../../editor/widgets/link.dart'; -import '../../l10n/extensions/localizations_ext.dart'; -import '../base_button/base_value_button.dart'; +import '../../../editor/widgets/link.dart'; +import '../../../l10n/extensions/localizations_ext.dart'; +import '../../base_button/base_value_button.dart'; -import '../config/simple_toolbar_config.dart'; -import '../theme/quill_dialog_theme.dart'; +import '../../config/simple_toolbar_config.dart'; +import '../../theme/quill_dialog_theme.dart'; -import 'quill_icon_button.dart'; +import '../quill_icon_button.dart'; typedef QuillToolbarLinkStyleBaseButton2 = QuillToolbarBaseButton< QuillToolbarLinkStyleButton2Options, diff --git a/lib/src/toolbar/buttons/link_style/link_style_button.dart b/lib/src/toolbar/buttons/link_style/link_style_button.dart new file mode 100644 index 000000000..768e55478 --- /dev/null +++ b/lib/src/toolbar/buttons/link_style/link_style_button.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../../../editor/widgets/link.dart'; +import '../../../l10n/extensions/localizations_ext.dart'; +import '../../base_button/base_value_button.dart'; + +import '../../config/buttons/link_style_options.dart'; +import '../quill_icon_button.dart'; +import 'link_dialog.dart'; + +typedef QuillToolbarLinkStyleBaseButton = QuillToolbarBaseButton< + QuillToolbarLinkStyleButtonOptions, + QuillToolbarLinkStyleButtonExtraOptions>; + +typedef QuillToolbarLinkStyleBaseButtonState< + W extends QuillToolbarLinkStyleBaseButton> + = QuillToolbarCommonButtonState; + +class QuillToolbarLinkStyleButton extends QuillToolbarLinkStyleBaseButton { + const QuillToolbarLinkStyleButton({ + required super.controller, + super.options = const QuillToolbarLinkStyleButtonOptions(), + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }); + + @override + QuillToolbarLinkStyleButtonState createState() => + QuillToolbarLinkStyleButtonState(); +} + +class QuillToolbarLinkStyleButtonState + extends QuillToolbarLinkStyleBaseButtonState { + @override + String get defaultTooltip => context.loc.insertURL; + + void _didChangeSelection() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant QuillToolbarLinkStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != controller) { + oldWidget.controller.removeListener(_didChangeSelection); + controller.addListener(_didChangeSelection); + } + } + + @override + void dispose() { + super.dispose(); + controller.removeListener(_didChangeSelection); + } + + @override + IconData get defaultIconData => Icons.link; + + @override + Widget build(BuildContext context) { + final isToggled = QuillTextLink.isSelected(controller); + + final childBuilder = this.childBuilder; + if (childBuilder != null) { + return childBuilder( + options, + QuillToolbarLinkStyleButtonExtraOptions( + context: context, + controller: controller, + onPressed: () { + _openLinkDialog(context); + afterButtonPressed?.call(); + }, + ), + ); + } + return QuillToolbarIconButton( + tooltip: tooltip, + icon: Icon( + iconData, + size: iconSize * iconButtonFactor, + ), + isSelected: isToggled, + onPressed: () => _openLinkDialog(context), + afterPressed: afterButtonPressed, + iconTheme: iconTheme, + ); + } + + Future _openLinkDialog(BuildContext context) async { + final initialTextLink = QuillTextLink.prepare(widget.controller); + + final textLink = await showDialog( + context: context, + builder: (_) { + return LinkDialog( + validateLink: options.validateLink, + // ignore: deprecated_member_use_from_same_package + legacyLinkRegExp: options.linkRegExp, + dialogTheme: options.dialogTheme, + text: initialTextLink.text, + link: initialTextLink.link, + action: options.linkDialogAction, + ); + }, + ); + if (textLink != null) { + textLink.submit(widget.controller); + } + } +} diff --git a/lib/src/toolbar/config/simple_toolbar_config.dart b/lib/src/toolbar/config/simple_toolbar_config.dart index 349bdcd26..f55e7ad48 100644 --- a/lib/src/toolbar/config/simple_toolbar_config.dart +++ b/lib/src/toolbar/config/simple_toolbar_config.dart @@ -3,8 +3,8 @@ import 'package:meta/meta.dart'; import '../buttons/hearder_style/select_header_style_buttons.dart'; import '../buttons/hearder_style/select_header_style_dropdown_button.dart'; -import '../buttons/link_style2_button.dart'; -import '../buttons/link_style_button.dart'; +import '../buttons/link_style/link_style2_button.dart'; +import '../buttons/link_style/link_style_button.dart'; import '../embed/embed_button_builder.dart'; import '../structs/link_dialog_action.dart'; import '../theme/quill_dialog_theme.dart'; diff --git a/lib/src/toolbar/simple_toolbar.dart b/lib/src/toolbar/simple_toolbar.dart index dbfd42687..7ca4a7313 100644 --- a/lib/src/toolbar/simple_toolbar.dart +++ b/lib/src/toolbar/simple_toolbar.dart @@ -18,8 +18,8 @@ export 'buttons/hearder_style/select_header_style_buttons.dart'; export 'buttons/hearder_style/select_header_style_dropdown_button.dart'; export 'buttons/history_button.dart'; export 'buttons/indent_button.dart'; -export 'buttons/link_style2_button.dart'; -export 'buttons/link_style_button.dart'; +export 'buttons/link_style/link_style2_button.dart'; +export 'buttons/link_style/link_style_button.dart'; export 'buttons/quill_icon_button.dart'; export 'buttons/search/search_button.dart'; export 'buttons/select_line_height_dropdown_button.dart'; diff --git a/test/common/utils/link_validator_test.dart b/test/common/utils/link_validator_test.dart index 4956a6314..2af5c7b7d 100644 --- a/test/common/utils/link_validator_test.dart +++ b/test/common/utils/link_validator_test.dart @@ -46,4 +46,60 @@ void main() { reason: 'Expected the link `$validLink` to be valid.'); } }); + + test('returns false when link is empty', () { + expect(LinkValidator.validate(''), false); + expect(LinkValidator.validate(' '), false); + }); + + test('calls customValidateLink and returns the result when not null', () { + var customCallbackCalled = false; + var mockResult = false; + + bool testCallback(link) { + customCallbackCalled = true; + return mockResult; + } + + expect(LinkValidator.validate('example', customValidateLink: testCallback), + mockResult); + expect(customCallbackCalled, true); + + mockResult = true; + expect(LinkValidator.validate('example', customValidateLink: testCallback), + mockResult); + }); + + test('supports legacyRegex when not null', () { + final exampleRegex = RegExp(r'\d{3}'); // Matches a 3-digit number + expect(LinkValidator.validate('link', legacyRegex: exampleRegex), false); + expect(LinkValidator.validate('412', legacyRegex: exampleRegex), true); + }); + + test('supports legacyAddationalLinkPrefixes', () { + expect( + LinkValidator.validate('app://example', + legacyAddationalLinkPrefixes: ['app://']), + true); + }); + + test('default linkPrefixes', () { + expect(LinkValidator.linkPrefixes, [ + 'mailto:', + 'tel:', + 'sms:', + 'callto:', + 'wtai:', + 'market:', + 'geopoint:', + 'ymsgr:', + 'msnim:', + 'gtalk:', + 'skype:', + 'sip:', + 'whatsapp:', + 'http://', + 'https://' + ]); + }); } diff --git a/test/common/utils/quill_test_app.dart b/test/common/utils/quill_test_app.dart index 0f03ff235..aaa2fddf4 100644 --- a/test/common/utils/quill_test_app.dart +++ b/test/common/utils/quill_test_app.dart @@ -3,6 +3,9 @@ import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/internal.dart'; import 'package:flutter_test/flutter_test.dart'; +typedef LocalizationsAvailableCallback = void Function( + FlutterQuillLocalizations quillLocalizations); + /// A utility for testing widgets within an application widget configured with /// the necessary localizations. /// @@ -35,6 +38,7 @@ class QuillTestApp extends StatelessWidget { QuillTestApp({ required this.home, required this.scaffoldBody, + this.onLocalizationsAvailable, super.key, }) { if (home != null && scaffoldBody != null) { @@ -43,12 +47,22 @@ class QuillTestApp extends StatelessWidget { } /// Creates a [QuillTestApp] with a [Scaffold] wrapping the given [body] widget. - factory QuillTestApp.withScaffold(Widget body) => - QuillTestApp(home: null, scaffoldBody: body); + factory QuillTestApp.withScaffold(Widget body, + {LocalizationsAvailableCallback? onLocalizationsAvailable}) => + QuillTestApp( + home: null, + scaffoldBody: body, + onLocalizationsAvailable: onLocalizationsAvailable, + ); /// Creates a [QuillTestApp] with the specified [home] widget. - factory QuillTestApp.home(Widget home) => - QuillTestApp(home: home, scaffoldBody: null); + factory QuillTestApp.home(Widget home, + {LocalizationsAvailableCallback? onLocalizationsAvailable}) => + QuillTestApp( + home: home, + scaffoldBody: null, + onLocalizationsAvailable: onLocalizationsAvailable, + ); /// The home widget for the application. /// @@ -60,15 +74,22 @@ class QuillTestApp extends StatelessWidget { /// If [scaffoldBody] is not null, [home] must be null. final Widget? scaffoldBody; + final LocalizationsAvailableCallback? onLocalizationsAvailable; + @override Widget build(BuildContext context) { return MaterialApp( localizationsDelegates: FlutterQuillLocalizations.localizationsDelegates, supportedLocales: FlutterQuillLocalizations.supportedLocales, - home: home ?? - Scaffold( - body: scaffoldBody, - ), + home: Builder(builder: (context) { + if (onLocalizationsAvailable != null) { + onLocalizationsAvailable?.call(context.loc); + } + return home ?? + Scaffold( + body: scaffoldBody, + ); + }), ); } } diff --git a/test/toolbar/buttons/link_style_button_test.dart b/test/toolbar/buttons/link_style_button_test.dart new file mode 100644 index 000000000..b19c90984 --- /dev/null +++ b/test/toolbar/buttons/link_style_button_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/src/common/utils/link_validator.dart'; +import 'package:flutter_quill/src/controller/quill_controller.dart'; +import 'package:flutter_quill/src/l10n/generated/quill_localizations.dart'; + +import 'package:flutter_quill/src/toolbar/buttons/link_style/link_dialog.dart'; +import 'package:flutter_quill/src/toolbar/buttons/link_style/link_style_button.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../common/utils/quill_test_app.dart'; + +void main() { + late QuillController controller; + + setUp(() => controller = QuillController.basic()); + tearDown(() => controller.dispose()); + + testWidgets('allows to insert valid links', (tester) async { + late FlutterQuillLocalizations loc; + await tester.pumpWidget( + QuillTestApp.withScaffold( + QuillToolbarLinkStyleButton(controller: controller), + onLocalizationsAvailable: (quillLocalizations) => + loc = quillLocalizations, + ), + ); + expect(find.byType(QuillToolbarLinkStyleButton), findsOneWidget); + + for (final linkPrefix in LinkValidator.linkPrefixes) { + await tester.tap(find.byType(QuillToolbarLinkStyleButton)); + await tester.pumpAndSettle(); + + expect(find.byType(LinkDialog), findsOne); + + await tester.enterText( + find.widgetWithText(TextFormField, loc.text), + 'Example', + ); + + final link = '${linkPrefix}example'; + await tester.enterText( + find.widgetWithText(TextFormField, loc.link), + link, + ); + + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(LinkDialog)) as LinkDialogState; + expect(state.canPress(), true); + + final okButtonFinder = find.widgetWithText(TextButton, loc.ok); + expect(okButtonFinder, findsOne); + + final okButton = tester.widget(okButtonFinder); + expect(okButton.onPressed, isNotNull); + + await tester.tap(okButtonFinder); + await tester.pumpAndSettle(); + + expect(find.byType(LinkDialog), findsNothing); + } + }); + + testWidgets('ok button is disabled for invalid links', (tester) async { + late FlutterQuillLocalizations loc; + await tester.pumpWidget( + QuillTestApp.withScaffold( + QuillToolbarLinkStyleButton(controller: controller), + onLocalizationsAvailable: (quillLocalizations) => + loc = quillLocalizations, + ), + ); + expect(find.byType(QuillToolbarLinkStyleButton), findsOneWidget); + + await tester.tap(find.byType(QuillToolbarLinkStyleButton)); + await tester.pumpAndSettle(); + + expect(find.byType(LinkDialog), findsOne); + + await tester.enterText( + find.widgetWithText(TextFormField, loc.text), + 'Example', + ); + + const link = 'example invalid link'; + await tester.enterText( + find.widgetWithText(TextFormField, loc.link), + link, + ); + + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(LinkDialog)) as LinkDialogState; + expect(state.canPress(), false); + + final okButtonFinder = find.widgetWithText(TextButton, loc.ok); + expect(okButtonFinder, findsOne); + + final okButton = tester.widget(okButtonFinder); + expect(okButton.onPressed, isNull); + + await tester.tap(okButtonFinder); + await tester.pumpAndSettle(); + + expect(find.byType(LinkDialog), findsOne); + }); +}