Skip to content

Commit 95b760a

Browse files
[Super Editor] - Make emails clickable with "mailto:", and linkify app URLs like "obsidian://" (Resolves #2426, Resolves #2427) (#2493)
1 parent bca57df commit 95b760a

File tree

9 files changed

+941
-499
lines changed

9 files changed

+941
-499
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:super_editor/super_editor.dart';
4+
import 'package:super_editor_markdown/super_editor_markdown.dart';
5+
6+
import 'spot_check_scaffold.dart';
7+
8+
class UrlLauncherSpotChecks extends StatefulWidget {
9+
const UrlLauncherSpotChecks({super.key});
10+
11+
@override
12+
State<UrlLauncherSpotChecks> createState() => _UrlLauncherSpotChecksState();
13+
}
14+
15+
class _UrlLauncherSpotChecksState extends State<UrlLauncherSpotChecks> {
16+
late final Editor _editor;
17+
18+
@override
19+
void initState() {
20+
super.initState();
21+
22+
_editor = createDefaultDocumentEditor(
23+
document: deserializeMarkdownToDocument('''
24+
# Linkification Spot Check
25+
In this spot check, we create a variety of linkification scenarios. We expect each link to be linkified, and to take the expect action when tapped.
26+
27+
## Markdown Links (with schemes)
28+
[https://google.com](https://google.com)
29+
30+
[obsidian://open?vault=my-vault](obsidian://open?vault=my-vault)
31+
32+
## Markdown Links (no schemes)
33+
[google.com](google.com)
34+
35+
36+
## Pasted Links
37+
The first set of pasted links are all pasted together within a single block of text. Then the same links are pasted with one link per line.
38+
'''),
39+
composer: MutableDocumentComposer(),
40+
);
41+
42+
_pasteLinks();
43+
}
44+
45+
Future<void> _pasteLinks() async {
46+
final links = '''
47+
48+
google.com https://google.com [email protected] mailto:[email protected] obsidian://open?vault=my-vault
49+
50+
google.com
51+
https://google.com
52+
53+
54+
obsidian://open?vault=my-vault
55+
''';
56+
57+
// Put the text on the clipboard.
58+
await Clipboard.setData(ClipboardData(text: links));
59+
60+
// Place the caret at the end of the document.
61+
// TODO: Add a startPosition and endPosition to `Document`.
62+
_editor.execute([
63+
ChangeSelectionRequest(
64+
DocumentSelection.collapsed(
65+
position: DocumentPosition(
66+
nodeId: _editor.document.last.id,
67+
nodePosition: (_editor.document.last as TextNode).endPosition,
68+
),
69+
),
70+
SelectionChangeType.placeCaret,
71+
SelectionReason.userInteraction,
72+
),
73+
]);
74+
75+
// Paste the text from the clipboard, which should include a linkification reaction.
76+
CommonEditorOperations(
77+
editor: _editor,
78+
document: _editor.document,
79+
composer: _editor.composer,
80+
documentLayoutResolver: () => throw UnimplementedError(),
81+
).paste();
82+
}
83+
84+
@override
85+
void dispose() {
86+
_editor.dispose();
87+
super.dispose();
88+
}
89+
90+
@override
91+
Widget build(BuildContext context) {
92+
return SpotCheckScaffold(
93+
content: SuperEditor(
94+
editor: _editor,
95+
stylesheet: defaultStylesheet.copyWith(
96+
addRulesAfter: [
97+
..._darkModeStyles,
98+
],
99+
),
100+
),
101+
);
102+
}
103+
}
104+
105+
final _darkModeStyles = [
106+
StyleRule(
107+
BlockSelector.all,
108+
(doc, docNode) {
109+
return {
110+
Styles.textStyle: const TextStyle(
111+
color: Color(0xFFCCCCCC),
112+
),
113+
};
114+
},
115+
),
116+
StyleRule(
117+
const BlockSelector("header1"),
118+
(doc, docNode) {
119+
return {
120+
Styles.textStyle: const TextStyle(
121+
color: Color(0xFF888888),
122+
),
123+
};
124+
},
125+
),
126+
StyleRule(
127+
const BlockSelector("header2"),
128+
(doc, docNode) {
129+
return {
130+
Styles.textStyle: const TextStyle(
131+
color: Color(0xFF888888),
132+
),
133+
};
134+
},
135+
),
136+
];

super_editor/example/lib/main.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'package:example/demos/in_the_lab/feature_stable_tags.dart';
2121
import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart';
2222
import 'package:example/demos/in_the_lab/spelling_error_decorations.dart';
2323
import 'package:example/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart';
24+
import 'package:example/demos/interaction_spot_checks/url_launching_spot_checks.dart';
2425
import 'package:example/demos/mobile_chat/demo_mobile_chat.dart';
2526
import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart';
2627
import 'package:example/demos/sliver_example_editor.dart';
@@ -387,6 +388,13 @@ final _menu = <_MenuGroup>[
387388
_MenuGroup(
388389
title: 'Spot Checks',
389390
items: [
391+
_MenuItem(
392+
icon: Icons.link,
393+
title: 'URL Parsing & Launching',
394+
pageBuilder: (context) {
395+
return UrlLauncherSpotChecks();
396+
},
397+
),
390398
_MenuItem(
391399
icon: Icons.layers,
392400
title: 'Toolbar Following Content Layer',

super_editor/lib/src/default_editor/attributions.dart

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ class FontFamilyAttribution implements Attribution {
207207
/// Attribution to be used within [AttributedText] to
208208
/// represent a link.
209209
///
210+
/// A link might be a URL or a URI. URLs are a subset of URIs.
211+
/// A URL begins with a scheme and "://", e.g., "https://" or
212+
/// "obsidian://". A URI begins with a scheme and a ":", e.g.,
213+
/// "mailto:" or "spotify:".
214+
///
210215
/// Every [LinkAttribution] is considered equivalent so
211216
/// that [AttributedText] prevents multiple [LinkAttribution]s
212217
/// from overlapping.
@@ -216,29 +221,89 @@ class FontFamilyAttribution implements Attribution {
216221
/// within [AttributedText]. This class doesn't have a special
217222
/// relationship with [AttributedText].
218223
class LinkAttribution implements Attribution {
224+
/// Creates a [LinkAttribution] from a structured [URI] (instead of plain text).
225+
///
226+
/// The [plainTextUri] for the returned [LinkAttribution] is set to
227+
/// the [uri]'s `toString()` value.
219228
factory LinkAttribution.fromUri(Uri uri) {
220-
return LinkAttribution(uri.toString());
229+
if (!uri.hasScheme) {
230+
// Without a scheme, a URI is fairly useless. We can't be sure
231+
// that any other part of the URI was parsed correctly if it
232+
// didn't begin with a scheme. Fallback to a plain text-only
233+
// attribution.
234+
return LinkAttribution(uri.toString());
235+
}
236+
237+
return LinkAttribution(uri.toString(), uri);
238+
}
239+
240+
/// Create a [LinkAttribution] based on a given [email] address.
241+
///
242+
/// This factory is equivalent to calling [LinkAttribution.fromUri]
243+
/// with a [Uri] whose `scheme` is "mailto" and whose `path` is [email].
244+
factory LinkAttribution.fromEmail(String email) {
245+
return LinkAttribution.fromUri(
246+
Uri(
247+
scheme: "mailto",
248+
path: email,
249+
),
250+
);
221251
}
222252

223-
const LinkAttribution(this.url);
253+
/// Creates a [LinkAttribution] whose plain-text URI is [plainTextUri], and
254+
/// which (optionally) includes a structured [Uri] version of the
255+
/// same URI.
256+
///
257+
/// [LinkAttribution] allows for text only creation because there may
258+
/// be situations where apps must apply link attributions to invalid
259+
/// URIs, such as when loading documents created elsewhere.
260+
const LinkAttribution(this.plainTextUri, [this.uri]);
224261

225262
@override
226263
String get id => 'link';
227264

228-
/// The URL associated with the attributed text, as a `String`.
229-
final String url;
265+
@Deprecated("Use plainTextUri instead. The term 'url' was a lie - it could always have been a URI.")
266+
String get url => plainTextUri;
230267

231-
/// Attempts to parse the [url] as a [Uri], and returns `true` if the [url]
232-
/// is successfully parsed, or `false` if parsing fails, such as due to the [url]
233-
/// including an invalid scheme, separator syntax, extra segments, etc.
234-
bool get hasValidUri => Uri.tryParse(url) != null;
268+
/// The URI associated with the attributed text, as a `String`.
269+
final String plainTextUri;
235270

236-
/// The URL associated with the attributed text, as a `Uri`.
271+
/// Returns `true` if this [LinkAttribution] has [uri], which is
272+
/// a structured representation of the associated URI.
273+
bool get hasStructuredUri => uri != null;
274+
275+
/// The structured [Uri] associated with this attribution's [plainTextUri].
276+
///
277+
/// In the nominal case, this [uri] has the same value as the [plainTextUri].
278+
/// However, in some cases, linkified text may have a [plainTextUri] that isn't
279+
/// a valid [Uri]. This can happen when an app creates or loads documents from
280+
/// other sources - one wants to retain link attributions, even if they're invalid.
281+
final Uri? uri;
282+
283+
/// Returns a best-guess version of this URI that an operating system can launch.
237284
///
238-
/// Accessing the [uri] throws an exception if the [url] isn't valid.
239-
/// To access a URL that might not be valid, consider accessing the [url],
240-
/// instead.
241-
Uri get uri => Uri.parse(url);
285+
/// In the nominal case, this value is the same as [uri] and [plainTextUri].
286+
///
287+
/// When no [uri] is available, this property either returns [plainTextUri] as-is,
288+
/// or inserts a best-guess scheme.
289+
Uri get launchableUri {
290+
if (hasStructuredUri) {
291+
return uri!;
292+
}
293+
294+
if (plainTextUri.contains("://")) {
295+
// It looks like the plain text URI has URL scheme. Return it as-is.
296+
return Uri.parse(plainTextUri);
297+
}
298+
299+
if (plainTextUri.contains("@")) {
300+
// Our best guess is that this is a URL.
301+
return Uri.parse("mailto:$plainTextUri");
302+
}
303+
304+
// Our best guess is that this is a web URL.
305+
return Uri.parse("https://$plainTextUri");
306+
}
242307

243308
@override
244309
bool canMergeWith(Attribution other) {
@@ -247,13 +312,14 @@ class LinkAttribution implements Attribution {
247312

248313
@override
249314
bool operator ==(Object other) =>
250-
identical(this, other) || other is LinkAttribution && runtimeType == other.runtimeType && url == other.url;
315+
identical(this, other) ||
316+
other is LinkAttribution && runtimeType == other.runtimeType && plainTextUri == other.plainTextUri;
251317

252318
@override
253-
int get hashCode => url.hashCode;
319+
int get hashCode => plainTextUri.hashCode;
254320

255321
@override
256322
String toString() {
257-
return '[LinkAttribution]: $url';
323+
return '[LinkAttribution]: $plainTextUri${hasStructuredUri ? ' ($uri)' : ''}';
258324
}
259325
}

super_editor/lib/src/default_editor/common_editor_operations.dart

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2506,7 +2506,7 @@ class PasteEditorCommand extends EditCommand {
25062506
attributedLines.add(
25072507
AttributedText(
25082508
line,
2509-
_findUrlSpansInText(pastedText: lines.first),
2509+
_findUrlSpansInText(pastedText: line),
25102510
),
25112511
);
25122512
}
@@ -2523,39 +2523,24 @@ class PasteEditorCommand extends EditCommand {
25232523
for (final wordBoundary in wordBoundaries) {
25242524
final word = wordBoundary.textInside(pastedText);
25252525

2526-
final extractedLinks = linkify(
2527-
word,
2528-
options: const LinkifyOptions(
2529-
humanize: false,
2530-
looseUrl: true,
2531-
),
2532-
);
2533-
2534-
final int linkCount = extractedLinks.fold(0, (value, element) => element is UrlElement ? value + 1 : value);
2535-
if (linkCount == 1) {
2536-
// The word is a single URL. Linkify it.
2537-
late final Uri uri;
2538-
try {
2539-
uri = parseLink(word);
2540-
} catch (exception) {
2541-
// Something went wrong when trying to parse links. This can happen, for example,
2542-
// due to Markdown syntax around a link, e.g., [My Link](www.something.com). I'm
2543-
// not sure why that case throws, but it does. We ignore any URL that throws.
2544-
continue;
2545-
}
2526+
// The word is a single URL. Linkify it.
2527+
final uri = tryToParseUrl(word);
2528+
if (uri == null) {
2529+
// This word isn't a URI.
2530+
continue;
2531+
}
25462532

2547-
final startOffset = wordBoundary.start;
2548-
// -1 because TextPosition's offset indexes the character after the
2549-
// selection, not the final character in the selection.
2550-
final endOffset = wordBoundary.end - 1;
2533+
final startOffset = wordBoundary.start;
2534+
// -1 because TextPosition's offset indexes the character after the
2535+
// selection, not the final character in the selection.
2536+
final endOffset = wordBoundary.end - 1;
25512537

2552-
// Add link attribution.
2553-
linkAttributionSpans.addAttribution(
2554-
newAttribution: LinkAttribution.fromUri(uri),
2555-
start: startOffset,
2556-
end: endOffset,
2557-
);
2558-
}
2538+
// Add link attribution.
2539+
linkAttributionSpans.addAttribution(
2540+
newAttribution: LinkAttribution.fromUri(uri),
2541+
start: startOffset,
2542+
end: endOffset,
2543+
);
25592544
}
25602545

25612546
return linkAttributionSpans;

0 commit comments

Comments
 (0)