Skip to content

feat: allow to override link validation check, and accept mailto and other links by default #2525

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 23, 2025
Merged
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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).

8 changes: 8 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -135,6 +135,14 @@ class _HomePageState extends State<HomePage> {
}
},
),
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;
},
),
),
),
),
98 changes: 98 additions & 0 deletions lib/src/common/utils/link_validator.dart
Original file line number Diff line number Diff line change
@@ -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<String>? 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));
}
}
15 changes: 11 additions & 4 deletions lib/src/editor/config/editor_config.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/// @docImport '../../rules/insert.dart' show AutoFormatMultipleLinksRule;
library;

import 'dart:ui' as ui;

import 'package:flutter/cupertino.dart';
@@ -13,7 +16,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 +419,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.
///
/// If a link is not valid and link launch is requested,
/// the editor will append `https://` as prefix to the link.
///
/// Useful for deep-links
/// This is used to tapping links within the editor, and not the toolbar or
/// [AutoFormatMultipleLinksRule].
final List<String> customLinkPrefixes;

/// Configures the dialog theme.
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions lib/src/editor/widgets/link.dart
Original file line number Diff line number Diff line change
@@ -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
16 changes: 9 additions & 7 deletions lib/src/editor/widgets/text/text_line.dart
Original file line number Diff line number Diff line change
@@ -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<TextLine> {
_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(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);
}

65 changes: 31 additions & 34 deletions lib/src/rules/insert.dart
Original file line number Diff line number Diff line change
@@ -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;
Loading