Skip to content

Commit c3af0d7

Browse files
committed
fix: improve customLinkPrefixes doc comment, allows to override link validation in the toolbar, allows to insert mailto and other links by default
1 parent b3beaea commit c3af0d7

File tree

11 files changed

+259
-90
lines changed

11 files changed

+259
-90
lines changed

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: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
44
import 'package:flutter/material.dart';
55
import 'package:meta/meta.dart' show experimental;
66

7+
import '../../../internal.dart' show AutoFormatMultipleLinksRule;
78
import '../../document/nodes/node.dart';
89
import '../../toolbar/theme/quill_dialog_theme.dart';
910
import '../embed/embed_editor_builder.dart';
@@ -13,7 +14,7 @@ import '../raw_editor/config/raw_editor_config.dart';
1314
import '../raw_editor/raw_editor.dart';
1415
import '../widgets/default_styles.dart';
1516
import '../widgets/delegate.dart';
16-
import '../widgets/link.dart';
17+
import '../widgets/link.dart' hide linkPrefixes;
1718
import '../widgets/text/utils/text_block_utils.dart';
1819
import 'search_config.dart';
1920

@@ -404,10 +405,14 @@ class QuillEditorConfig {
404405

405406
final bool detectWordBoundary;
406407

407-
/// Additional list if links prefixes, which must not be prepended
408-
/// with "https://" when [LinkMenuAction.launch] happened
408+
/// Link prefixes that are addations to [linkPrefixes], which are used
409+
/// on link launch [LinkMenuAction.launch] to check whether a link is valid.
409410
///
410-
/// Useful for deep-links
411+
/// If a link is not valid and link launch is requested,
412+
/// the editor will append `https://` as prefix to the link.
413+
///
414+
/// This is used to tapping links within the editor, and not the toolbar or
415+
/// [AutoFormatMultipleLinksRule].
411416
final List<String> customLinkPrefixes;
412417

413418
/// Configures the dialog theme.

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: Maybe we should refactor onLaunchUrl or add a new API to guve full control of the launch?
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;

lib/src/toolbar/buttons/link_style2_button.dart

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import 'package:flutter/foundation.dart';
22
import 'package:flutter/material.dart';
33
import 'package:url_launcher/link.dart';
44

5+
import '../../common/utils/link_validator.dart';
56
import '../../common/utils/widgets.dart';
67

78
import '../../editor/widgets/link.dart';
89
import '../../l10n/extensions/localizations_ext.dart';
9-
import '../../rules/insert.dart';
1010
import '../base_button/base_value_button.dart';
1111

1212
import '../config/simple_toolbar_config.dart';
@@ -359,15 +359,14 @@ class _LinkStyleDialogState extends State<LinkStyleDialog> {
359359

360360
bool _canPress() => _validateLink(_link) == null;
361361

362-
String? _validateLink(String? value) {
363-
if ((value?.isEmpty ?? false) ||
364-
!const AutoFormatMultipleLinksRule()
365-
.oneLineLinkRegExp
366-
.hasMatch(value!)) {
367-
return widget.validationMessage ?? 'That is not a valid URL';
368-
}
362+
String? _validateLink(final String? value) {
363+
final input = value ?? '';
369364

370-
return null;
365+
final errorMessage = LinkValidator.validate(input)
366+
? null
367+
// TODO: Translate
368+
: (widget.validationMessage ?? 'That is not a valid URL');
369+
return errorMessage;
371370
}
372371

373372
void _applyLink() =>

0 commit comments

Comments
 (0)