Skip to content

Commit

Permalink
[Super Editor] - Make emails clickable with "mailto:", and linkify ap…
Browse files Browse the repository at this point in the history
…p URLs like "obsidian://" (Resolves #2426, Resolves #2427) (#2493)
  • Loading branch information
matthew-carroll authored Jan 8, 2025
1 parent ee55d19 commit 85a49b2
Show file tree
Hide file tree
Showing 9 changed files with 941 additions and 499 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:super_editor/super_editor.dart';
import 'package:super_editor_markdown/super_editor_markdown.dart';

import 'spot_check_scaffold.dart';

class UrlLauncherSpotChecks extends StatefulWidget {
const UrlLauncherSpotChecks({super.key});

@override
State<UrlLauncherSpotChecks> createState() => _UrlLauncherSpotChecksState();
}

class _UrlLauncherSpotChecksState extends State<UrlLauncherSpotChecks> {
late final Editor _editor;

@override
void initState() {
super.initState();

_editor = createDefaultDocumentEditor(
document: deserializeMarkdownToDocument('''
# Linkification Spot Check
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.
## Markdown Links (with schemes)
[https://google.com](https://google.com)
[mailto:[email protected]](mailto:[email protected])
[obsidian://open?vault=my-vault](obsidian://open?vault=my-vault)
## Markdown Links (no schemes)
[google.com](google.com)
[[email protected]]([email protected])
## Pasted Links
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.
'''),
composer: MutableDocumentComposer(),
);

_pasteLinks();
}

Future<void> _pasteLinks() async {
final links = '''
google.com https://google.com [email protected] mailto:[email protected] obsidian://open?vault=my-vault
google.com
https://google.com
[email protected]
mailto:[email protected]
obsidian://open?vault=my-vault
''';

// Put the text on the clipboard.
await Clipboard.setData(ClipboardData(text: links));

// Place the caret at the end of the document.
// TODO: Add a startPosition and endPosition to `Document`.
_editor.execute([
ChangeSelectionRequest(
DocumentSelection.collapsed(
position: DocumentPosition(
nodeId: _editor.document.last.id,
nodePosition: (_editor.document.last as TextNode).endPosition,
),
),
SelectionChangeType.placeCaret,
SelectionReason.userInteraction,
),
]);

// Paste the text from the clipboard, which should include a linkification reaction.
CommonEditorOperations(
editor: _editor,
document: _editor.document,
composer: _editor.composer,
documentLayoutResolver: () => throw UnimplementedError(),
).paste();
}

@override
void dispose() {
_editor.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return SpotCheckScaffold(
content: SuperEditor(
editor: _editor,
stylesheet: defaultStylesheet.copyWith(
addRulesAfter: [
..._darkModeStyles,
],
),
),
);
}
}

final _darkModeStyles = [
StyleRule(
BlockSelector.all,
(doc, docNode) {
return {
Styles.textStyle: const TextStyle(
color: Color(0xFFCCCCCC),
),
};
},
),
StyleRule(
const BlockSelector("header1"),
(doc, docNode) {
return {
Styles.textStyle: const TextStyle(
color: Color(0xFF888888),
),
};
},
),
StyleRule(
const BlockSelector("header2"),
(doc, docNode) {
return {
Styles.textStyle: const TextStyle(
color: Color(0xFF888888),
),
};
},
),
];
8 changes: 8 additions & 0 deletions super_editor/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:example/demos/in_the_lab/feature_stable_tags.dart';
import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart';
import 'package:example/demos/in_the_lab/spelling_error_decorations.dart';
import 'package:example/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart';
import 'package:example/demos/interaction_spot_checks/url_launching_spot_checks.dart';
import 'package:example/demos/mobile_chat/demo_mobile_chat.dart';
import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart';
import 'package:example/demos/sliver_example_editor.dart';
Expand Down Expand Up @@ -387,6 +388,13 @@ final _menu = <_MenuGroup>[
_MenuGroup(
title: 'Spot Checks',
items: [
_MenuItem(
icon: Icons.link,
title: 'URL Parsing & Launching',
pageBuilder: (context) {
return UrlLauncherSpotChecks();
},
),
_MenuItem(
icon: Icons.layers,
title: 'Toolbar Following Content Layer',
Expand Down
98 changes: 82 additions & 16 deletions super_editor/lib/src/default_editor/attributions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ class FontFamilyAttribution implements Attribution {
/// Attribution to be used within [AttributedText] to
/// represent a link.
///
/// A link might be a URL or a URI. URLs are a subset of URIs.
/// A URL begins with a scheme and "://", e.g., "https://" or
/// "obsidian://". A URI begins with a scheme and a ":", e.g.,
/// "mailto:" or "spotify:".
///
/// Every [LinkAttribution] is considered equivalent so
/// that [AttributedText] prevents multiple [LinkAttribution]s
/// from overlapping.
Expand All @@ -216,29 +221,89 @@ class FontFamilyAttribution implements Attribution {
/// within [AttributedText]. This class doesn't have a special
/// relationship with [AttributedText].
class LinkAttribution implements Attribution {
/// Creates a [LinkAttribution] from a structured [URI] (instead of plain text).
///
/// The [plainTextUri] for the returned [LinkAttribution] is set to
/// the [uri]'s `toString()` value.
factory LinkAttribution.fromUri(Uri uri) {
return LinkAttribution(uri.toString());
if (!uri.hasScheme) {
// Without a scheme, a URI is fairly useless. We can't be sure
// that any other part of the URI was parsed correctly if it
// didn't begin with a scheme. Fallback to a plain text-only
// attribution.
return LinkAttribution(uri.toString());
}

return LinkAttribution(uri.toString(), uri);
}

/// Create a [LinkAttribution] based on a given [email] address.
///
/// This factory is equivalent to calling [LinkAttribution.fromUri]
/// with a [Uri] whose `scheme` is "mailto" and whose `path` is [email].
factory LinkAttribution.fromEmail(String email) {
return LinkAttribution.fromUri(
Uri(
scheme: "mailto",
path: email,
),
);
}

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

@override
String get id => 'link';

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

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

/// The URL associated with the attributed text, as a `Uri`.
/// Returns `true` if this [LinkAttribution] has [uri], which is
/// a structured representation of the associated URI.
bool get hasStructuredUri => uri != null;

/// The structured [Uri] associated with this attribution's [plainTextUri].
///
/// In the nominal case, this [uri] has the same value as the [plainTextUri].
/// However, in some cases, linkified text may have a [plainTextUri] that isn't
/// a valid [Uri]. This can happen when an app creates or loads documents from
/// other sources - one wants to retain link attributions, even if they're invalid.
final Uri? uri;

/// Returns a best-guess version of this URI that an operating system can launch.
///
/// Accessing the [uri] throws an exception if the [url] isn't valid.
/// To access a URL that might not be valid, consider accessing the [url],
/// instead.
Uri get uri => Uri.parse(url);
/// In the nominal case, this value is the same as [uri] and [plainTextUri].
///
/// When no [uri] is available, this property either returns [plainTextUri] as-is,
/// or inserts a best-guess scheme.
Uri get launchableUri {
if (hasStructuredUri) {
return uri!;
}

if (plainTextUri.contains("://")) {
// It looks like the plain text URI has URL scheme. Return it as-is.
return Uri.parse(plainTextUri);
}

if (plainTextUri.contains("@")) {
// Our best guess is that this is a URL.
return Uri.parse("mailto:$plainTextUri");
}

// Our best guess is that this is a web URL.
return Uri.parse("https://$plainTextUri");
}

@override
bool canMergeWith(Attribution other) {
Expand All @@ -247,13 +312,14 @@ class LinkAttribution implements Attribution {

@override
bool operator ==(Object other) =>
identical(this, other) || other is LinkAttribution && runtimeType == other.runtimeType && url == other.url;
identical(this, other) ||
other is LinkAttribution && runtimeType == other.runtimeType && plainTextUri == other.plainTextUri;

@override
int get hashCode => url.hashCode;
int get hashCode => plainTextUri.hashCode;

@override
String toString() {
return '[LinkAttribution]: $url';
return '[LinkAttribution]: $plainTextUri${hasStructuredUri ? ' ($uri)' : ''}';
}
}
49 changes: 17 additions & 32 deletions super_editor/lib/src/default_editor/common_editor_operations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2506,7 +2506,7 @@ class PasteEditorCommand extends EditCommand {
attributedLines.add(
AttributedText(
line,
_findUrlSpansInText(pastedText: lines.first),
_findUrlSpansInText(pastedText: line),
),
);
}
Expand All @@ -2523,39 +2523,24 @@ class PasteEditorCommand extends EditCommand {
for (final wordBoundary in wordBoundaries) {
final word = wordBoundary.textInside(pastedText);

final extractedLinks = linkify(
word,
options: const LinkifyOptions(
humanize: false,
looseUrl: true,
),
);

final int linkCount = extractedLinks.fold(0, (value, element) => element is UrlElement ? value + 1 : value);
if (linkCount == 1) {
// The word is a single URL. Linkify it.
late final Uri uri;
try {
uri = parseLink(word);
} catch (exception) {
// Something went wrong when trying to parse links. This can happen, for example,
// due to Markdown syntax around a link, e.g., [My Link](www.something.com). I'm
// not sure why that case throws, but it does. We ignore any URL that throws.
continue;
}
// The word is a single URL. Linkify it.
final uri = tryToParseUrl(word);
if (uri == null) {
// This word isn't a URI.
continue;
}

final startOffset = wordBoundary.start;
// -1 because TextPosition's offset indexes the character after the
// selection, not the final character in the selection.
final endOffset = wordBoundary.end - 1;
final startOffset = wordBoundary.start;
// -1 because TextPosition's offset indexes the character after the
// selection, not the final character in the selection.
final endOffset = wordBoundary.end - 1;

// Add link attribution.
linkAttributionSpans.addAttribution(
newAttribution: LinkAttribution.fromUri(uri),
start: startOffset,
end: endOffset,
);
}
// Add link attribution.
linkAttributionSpans.addAttribution(
newAttribution: LinkAttribution.fromUri(uri),
start: startOffset,
end: endOffset,
);
}

return linkAttributionSpans;
Expand Down
Loading

0 comments on commit 85a49b2

Please sign in to comment.