Skip to content

Commit 86b1e6a

Browse files
authored
feat: allow to override link validation check, and accept mailto and other links by default (#2525)
* fix: improve customLinkPrefixes doc comment, allows to override link validation in the toolbar, allows to insert mailto and other links by default * docs(changelog): document the changes with the PR link * test: adds unit and widget tests
1 parent 2e19b9e commit 86b1e6a

File tree

19 files changed

+750
-346
lines changed

19 files changed

+750
-346
lines changed

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
1111
## [Unreleased]
1212

13+
### Added
14+
15+
- 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).
16+
- `validateLink` in `QuillToolbarLinkStyleButtonOptions` to allow overriding the link validation [#2525](https://github.com/singerdmx/flutter-quill/pull/2525).
17+
18+
### Fixed
19+
20+
- Improve doc comment of `customLinkPrefixes` in `QuillEditor` [#2525](https://github.com/singerdmx/flutter-quill/pull/2525).
21+
22+
### Changed
23+
24+
- Deprecate `linkRegExp` in favor of the new callback `validateLink` [#2525](https://github.com/singerdmx/flutter-quill/pull/2525).
25+
1326
## [11.3.0] - 2025-04-23
1427

1528
### Fixed
@@ -22,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2235

2336
## [11.2.0] - 2025-03-26
2437

25-
### Added
38+
### Added
2639

2740
- Cache for `toPlainText` in `Document` class to avoid unnecessary text computing [#2482](https://github.com/singerdmx/flutter-quill/pull/2482).
2841

example/lib/main.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ class _HomePageState extends State<HomePage> {
135135
}
136136
},
137137
),
138+
linkStyle: QuillToolbarLinkStyleButtonOptions(
139+
validateLink: (link) {
140+
// Treats all links as valid. When launching the URL,
141+
// `https://` is prefixed if the link is incomplete (e.g., `google.com` → `https://google.com`)
142+
// however this happens only within the editor.
143+
return true;
144+
},
145+
),
138146
),
139147
),
140148
),
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
@internal
2+
library;
3+
4+
import 'package:meta/meta.dart';
5+
6+
/// {@template link_validation_callback}
7+
/// A callback to validate whether the [link] is valid.
8+
///
9+
/// The [link] is passed to the callback, which should return `true` if valid,
10+
/// or `false` otherwise.
11+
///
12+
/// Example:
13+
///
14+
/// ```dart
15+
/// validateLink: (link) {
16+
/// if (link.startsWith('ws')) {
17+
/// return true; // WebSocket links are considered valid
18+
/// }
19+
/// final regex = RegExp(r'^(http|https)://[a-zA-Z0-9.-]+');
20+
/// return regex.hasMatch(link);
21+
/// }
22+
/// ```
23+
///
24+
/// Return `null` to fallback to the default handling:
25+
///
26+
/// ```dart
27+
/// validateLink: (link) {
28+
/// if (link.startsWith('custom')) {
29+
/// return true;
30+
/// }
31+
/// return null;
32+
/// }
33+
/// ```
34+
///
35+
/// Another example to allow inserting any link:
36+
///
37+
/// ```dart
38+
/// validateLink: (link) {
39+
/// // Treats all links as valid. When launching the URL,
40+
/// // `https://` is prefixed if the link is incomplete (e.g., `google.com` → `https://google.com`)
41+
/// // however this happens only within the editor level and the
42+
/// // the URL will be stored as:
43+
/// // {insert: ..., attributes: {link: google.com}}
44+
/// return true;
45+
/// }
46+
/// ```
47+
///
48+
/// NOTE: The link will always be considered invalid if empty, and this callback will
49+
/// not be called.
50+
///
51+
/// {@endtemplate}
52+
typedef LinkValidationCallback = bool? Function(String link);
53+
54+
abstract final class LinkValidator {
55+
static const linkPrefixes = [
56+
'mailto:', // email
57+
'tel:', // telephone
58+
'sms:', // SMS
59+
'callto:',
60+
'wtai:',
61+
'market:',
62+
'geopoint:',
63+
'ymsgr:',
64+
'msnim:',
65+
'gtalk:', // Google Talk
66+
'skype:',
67+
'sip:', // Lync
68+
'whatsapp:',
69+
'http://',
70+
'https://'
71+
];
72+
73+
static bool validate(
74+
String link, {
75+
LinkValidationCallback? customValidateLink,
76+
RegExp? legacyRegex,
77+
List<String>? legacyAddationalLinkPrefixes,
78+
}) {
79+
if (link.trim().isEmpty) {
80+
return false;
81+
}
82+
if (customValidateLink != null) {
83+
final isValid = customValidateLink(link);
84+
if (isValid != null) {
85+
return isValid;
86+
}
87+
}
88+
// Implemented for backward compatibility, clients should use validateLink instead.
89+
// ignore: deprecated_member_use_from_same_package
90+
final legacyRegexp = legacyRegex;
91+
if (legacyRegexp?.hasMatch(link) == true) {
92+
return true;
93+
}
94+
// Implemented for backward compatibility, clients should use validateLink instead.
95+
return (linkPrefixes + (legacyAddationalLinkPrefixes ?? []))
96+
.any((prefix) => link.startsWith(prefix));
97+
}
98+
}

lib/src/editor/config/editor_config.dart

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/// @docImport '../../rules/insert.dart' show AutoFormatMultipleLinksRule;
2+
library;
3+
14
import 'dart:ui' as ui;
25

36
import 'package:flutter/cupertino.dart';
@@ -13,7 +16,7 @@ import '../raw_editor/config/raw_editor_config.dart';
1316
import '../raw_editor/raw_editor.dart';
1417
import '../widgets/default_styles.dart';
1518
import '../widgets/delegate.dart';
16-
import '../widgets/link.dart';
19+
import '../widgets/link.dart' hide linkPrefixes;
1720
import '../widgets/text/magnifier.dart';
1821
import '../widgets/text/utils/text_block_utils.dart';
1922
import 'search_config.dart';
@@ -416,10 +419,14 @@ class QuillEditorConfig {
416419

417420
final bool detectWordBoundary;
418421

419-
/// Additional list if links prefixes, which must not be prepended
420-
/// with "https://" when [LinkMenuAction.launch] happened
422+
/// Link prefixes that are addations to [linkPrefixes], which are used
423+
/// on link launch [LinkMenuAction.launch] to check whether a link is valid.
424+
///
425+
/// If a link is not valid and link launch is requested,
426+
/// the editor will append `https://` as prefix to the link.
421427
///
422-
/// Useful for deep-links
428+
/// This is used to tapping links within the editor, and not the toolbar or
429+
/// [AutoFormatMultipleLinksRule].
423430
final List<String> customLinkPrefixes;
424431

425432
/// Configures the dialog theme.

lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcut_actions.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
22

33
import '../../../document/attribute.dart';
44
import '../../../document/style.dart';
5-
import '../../../toolbar/buttons/link_style2_button.dart';
5+
import '../../../toolbar/buttons/link_style/link_style2_button.dart';
66
import '../../../toolbar/buttons/search/search_dialog.dart';
77
import '../../editor.dart';
88
import '../../widgets/link.dart';

lib/src/editor/widgets/link.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import 'package:flutter/cupertino.dart';
22
import 'package:flutter/foundation.dart';
33
import 'package:flutter/material.dart';
4+
import 'package:meta/meta.dart';
45

56
import '../../controller/quill_controller.dart';
67
import '../../document/attribute.dart';
78
import '../../document/nodes/node.dart';
89
import '../../l10n/extensions/localizations_ext.dart';
910

11+
@Deprecated(
12+
'Moved to LinkValidator.linkPrefixes but no longer available with the public'
13+
'API. The item `http` has been removed and replaced with `http://` and `https://`.',
14+
)
15+
@internal
1016
const linkPrefixes = [
1117
'mailto:', // email
1218
'tel:', // telephone

lib/src/editor/widgets/text/text_line.dart

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
1111
import '../../../../flutter_quill.dart';
1212
import '../../../common/utils/color.dart';
1313
import '../../../common/utils/font.dart';
14+
import '../../../common/utils/link_validator.dart';
1415
import '../../../common/utils/platform.dart';
1516
import '../../../document/nodes/container.dart' as container_node;
1617
import '../../../document/nodes/leaf.dart' as leaf;
@@ -671,19 +672,20 @@ class _TextLineState extends State<TextLine> {
671672
_tapLink(link);
672673
}
673674

674-
void _tapLink(String? link) {
675+
void _tapLink(final String? inputLink) {
676+
var link = inputLink?.trim();
675677
if (link == null) {
676678
return;
677679
}
678680

679-
var launchUrl = widget.onLaunchUrl;
680-
launchUrl ??= _launchUrl;
681-
682-
link = link.trim();
683-
if (!(widget.customLinkPrefixes + linkPrefixes)
684-
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
681+
final isValidLink = LinkValidator.validate(link,
682+
legacyAddationalLinkPrefixes: widget.customLinkPrefixes);
683+
if (!isValidLink) {
685684
link = 'https://$link';
686685
}
686+
687+
// 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
688+
final launchUrl = widget.onLaunchUrl ?? _launchUrl;
687689
launchUrl(link);
688690
}
689691

lib/src/rules/insert.dart

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import 'package:flutter/widgets.dart' show immutable;
1+
import 'package:meta/meta.dart';
22

33
import '../../quill_delta.dart';
44
import '../common/extensions/uri_ext.dart';
@@ -362,24 +362,38 @@ class AutoFormatMultipleLinksRule extends InsertRule {
362362
// https://example.net/
363363
// URL generator tool (https://www.randomlists.com/urls) is used.
364364

365-
static const _oneLineLinkPattern =
366-
r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#].*)?$';
367-
static const _detectLinkPattern =
368-
r'https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#][^\s]*)?';
369-
370-
/// It requires a valid link in one link
371-
RegExp get oneLineLinkRegExp => RegExp(
372-
_oneLineLinkPattern,
365+
/// A regular expression to match a single-line URL
366+
@internal
367+
static RegExp get singleLineUrlRegExp => RegExp(
368+
r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#].*)?$',
373369
caseSensitive: false,
374370
);
375371

376-
/// It detect if there is a link in the text whatever if it in the middle etc
377-
// Used to solve bug https://github.com/singerdmx/flutter-quill/issues/1432
378-
RegExp get detectLinkRegExp => RegExp(
379-
_detectLinkPattern,
372+
/// A regular expression to detect a URL anywhere in the text, even if it's in the middle of other content.
373+
/// Used to resolve bug https://github.com/singerdmx/flutter-quill/issues/1432
374+
@internal
375+
static RegExp get urlInTextRegExp => RegExp(
376+
r'https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/\?#][^\s]*)?',
380377
caseSensitive: false,
381378
);
382-
RegExp get linkRegExp => oneLineLinkRegExp;
379+
380+
@Deprecated(
381+
'Deprecated and will be removed in future-releasese as this is not the place to store regex.\n'
382+
'Please use a custom regex instead or use AutoFormatMultipleLinksRule.singleLineUrlRegExp which is an internal API.',
383+
)
384+
RegExp get oneLineLinkRegExp => singleLineUrlRegExp;
385+
386+
@Deprecated(
387+
'Deprecated and will be removed in future-releasese as this is not the place to store regex.\n'
388+
'Please use a custom regex instead or use AutoFormatMultipleLinksRule.urlInTextRegExp which is an internal API.',
389+
)
390+
RegExp get detectLinkRegExp => urlInTextRegExp;
391+
392+
@Deprecated(
393+
'No longer used and will be silently ignored. Please use custom regex '
394+
'or use AutoFormatMultipleLinksRule.singleLineUrlRegExp which is an internal API.',
395+
)
396+
RegExp get linkRegExp => singleLineUrlRegExp;
383397

384398
@override
385399
Delta? applyRule(
@@ -388,6 +402,8 @@ class AutoFormatMultipleLinksRule extends InsertRule {
388402
int? len,
389403
Object? data,
390404
Attribute? attribute,
405+
@Deprecated(
406+
'No longer used and will be silently ignored and removed in future releases.')
391407
Object? extraData,
392408
}) {
393409
// Only format when inserting text.
@@ -423,27 +439,8 @@ class AutoFormatMultipleLinksRule extends InsertRule {
423439
// Build the segment of affected words.
424440
final affectedWords = '$leftWordPart$data$rightWordPart';
425441

426-
var usedRegExp = detectLinkRegExp;
427-
final alternativeLinkRegExp = extraData;
428-
if (alternativeLinkRegExp != null) {
429-
try {
430-
if (alternativeLinkRegExp is! String) {
431-
throw ArgumentError.value(
432-
alternativeLinkRegExp,
433-
'alternativeLinkRegExp',
434-
'`alternativeLinkRegExp` should be of type String',
435-
);
436-
}
437-
final regPattern = alternativeLinkRegExp;
438-
usedRegExp = RegExp(
439-
regPattern,
440-
caseSensitive: false,
441-
);
442-
} catch (_) {}
443-
}
444-
445442
// Check for URL pattern.
446-
final matches = usedRegExp.allMatches(affectedWords);
443+
final matches = urlInTextRegExp.allMatches(affectedWords);
447444

448445
// If there are no matches, do not apply any format.
449446
if (matches.isEmpty) return null;

0 commit comments

Comments
 (0)