Skip to content

Commit 2080aab

Browse files
committed
Launch link improvements
Allows launching links in editing mode, where: * For desktop platforms: links launch on `Cmd` + `Click` (macOS) or `Ctrl` + `Click` (windows, linux) * For mobile platforms: long-pressing a link shows a context menu with multiple actions (Open, Copy, Remove) for the user to choose from.
1 parent c8b50d9 commit 2080aab

File tree

6 files changed

+541
-53
lines changed

6 files changed

+541
-53
lines changed

lib/src/widgets/editor.dart

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ import 'package:flutter/material.dart';
99
import 'package:flutter/rendering.dart';
1010
import 'package:flutter/services.dart';
1111
import 'package:string_validator/string_validator.dart';
12-
import 'package:url_launcher/url_launcher.dart';
1312

14-
import '../models/documents/attribute.dart';
1513
import '../models/documents/document.dart';
1614
import '../models/documents/nodes/container.dart' as container_node;
1715
import '../models/documents/nodes/embed.dart';
@@ -25,6 +23,7 @@ import 'default_styles.dart';
2523
import 'delegate.dart';
2624
import 'float_cursor.dart';
2725
import 'image.dart';
26+
import 'link.dart';
2827
import 'raw_editor.dart';
2928
import 'text_selection.dart';
3029
import 'video_app.dart';
@@ -246,6 +245,7 @@ class QuillEditor extends StatefulWidget {
246245
this.onSingleLongTapMoveUpdate,
247246
this.onSingleLongTapEnd,
248247
this.embedBuilder = defaultEmbedBuilder,
248+
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
249249
this.customStyleBuilder,
250250
this.floatingCursorDisabled = false,
251251
Key? key});
@@ -312,6 +312,21 @@ class QuillEditor extends StatefulWidget {
312312
final EmbedBuilder embedBuilder;
313313
final CustomStyleBuilder? customStyleBuilder;
314314

315+
/// Delegate function responsible for showing menu with link actions on
316+
/// mobile platforms (iOS, Android).
317+
///
318+
/// The menu is triggered in editing mode ([readOnly] is set to `false`)
319+
/// when the user long-presses a link-styled text segment.
320+
///
321+
/// FlutterQuill provides default implementation which can be overridden by
322+
/// this field to customize the user experience.
323+
///
324+
/// By default on iOS the menu is displayed with [showCupertinoModalPopup]
325+
/// which constructs an instance of [CupertinoActionSheet]. For Android,
326+
/// the menu is displayed with [showModalBottomSheet] and a list of
327+
/// Material [ListTile]s.
328+
final LinkActionPickerDelegate linkActionPickerDelegate;
329+
315330
final bool floatingCursorDisabled;
316331

317332
@override
@@ -415,6 +430,7 @@ class _QuillEditorState extends State<QuillEditor>
415430
enableInteractiveSelection: widget.enableInteractiveSelection,
416431
scrollPhysics: widget.scrollPhysics,
417432
embedBuilder: widget.embedBuilder,
433+
linkActionPickerDelegate: widget.linkActionPickerDelegate,
418434
customStyleBuilder: widget.customStyleBuilder,
419435
floatingCursorDisabled: widget.floatingCursorDisabled,
420436
);
@@ -520,20 +536,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
520536
return false;
521537
}
522538
final segment = segmentResult.node as leaf.Leaf;
523-
if (segment.style.containsKey(Attribute.link.key)) {
524-
var launchUrl = getEditor()!.widget.onLaunchUrl;
525-
launchUrl ??= _launchUrl;
526-
String? link = segment.style.attributes[Attribute.link.key]!.value;
527-
if (getEditor()!.widget.readOnly && link != null) {
528-
link = link.trim();
529-
if (!linkPrefixes
530-
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
531-
link = 'https://$link';
532-
}
533-
launchUrl(link);
534-
}
535-
return false;
536-
}
537539
if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
538540
final blockEmbed = segment.value as BlockEmbed;
539541
if (blockEmbed.type == 'image') {
@@ -557,10 +559,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
557559
return false;
558560
}
559561

560-
Future<void> _launchUrl(String url) async {
561-
await launch(url);
562-
}
563-
564562
@override
565563
void onTapDown(TapDownDetails details) {
566564
if (_state.widget.onTapDown != null) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
4+
class QuillPressedKeys extends ChangeNotifier {
5+
static QuillPressedKeys of(BuildContext context) {
6+
final widget =
7+
context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>();
8+
return widget!.pressedKeys;
9+
}
10+
11+
bool _metaPressed = false;
12+
bool _controlPressed = false;
13+
14+
/// Whether meta key is currently pressed.
15+
bool get metaPressed => _metaPressed;
16+
17+
/// Whether control key is currently pressed.
18+
bool get controlPressed => _controlPressed;
19+
20+
void _updatePressedKeys(Set<LogicalKeyboardKey> pressedKeys) {
21+
final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) ||
22+
pressedKeys.contains(LogicalKeyboardKey.metaRight);
23+
final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) ||
24+
pressedKeys.contains(LogicalKeyboardKey.controlRight);
25+
if (_metaPressed != meta || _controlPressed != control) {
26+
_metaPressed = meta;
27+
_controlPressed = control;
28+
notifyListeners();
29+
}
30+
}
31+
}
32+
33+
class QuillKeyboardListener extends StatefulWidget {
34+
const QuillKeyboardListener({required this.child, Key? key})
35+
: super(key: key);
36+
37+
final Widget child;
38+
39+
@override
40+
QuillKeyboardListenerState createState() => QuillKeyboardListenerState();
41+
}
42+
43+
class QuillKeyboardListenerState extends State<QuillKeyboardListener> {
44+
final QuillPressedKeys _pressedKeys = QuillPressedKeys();
45+
46+
bool _keyEvent(KeyEvent event) {
47+
_pressedKeys
48+
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
49+
return false;
50+
}
51+
52+
@override
53+
void initState() {
54+
super.initState();
55+
HardwareKeyboard.instance.addHandler(_keyEvent);
56+
_pressedKeys
57+
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
58+
}
59+
60+
@override
61+
void dispose() {
62+
HardwareKeyboard.instance.removeHandler(_keyEvent);
63+
_pressedKeys.dispose();
64+
super.dispose();
65+
}
66+
67+
@override
68+
Widget build(BuildContext context) {
69+
return _QuillPressedKeysAccess(
70+
pressedKeys: _pressedKeys,
71+
child: widget.child,
72+
);
73+
}
74+
}
75+
76+
class _QuillPressedKeysAccess extends InheritedWidget {
77+
const _QuillPressedKeysAccess({
78+
required this.pressedKeys,
79+
required Widget child,
80+
Key? key,
81+
}) : super(key: key, child: child);
82+
83+
final QuillPressedKeys pressedKeys;
84+
85+
@override
86+
bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) {
87+
return oldWidget.pressedKeys != pressedKeys;
88+
}
89+
}

lib/src/widgets/link.dart

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import 'package:flutter/cupertino.dart';
2+
import 'package:flutter/foundation.dart';
3+
import 'package:flutter/material.dart';
4+
5+
import '../../models/documents/nodes/node.dart';
6+
7+
/// List of possible actions returned from [LinkActionPickerDelegate].
8+
enum LinkMenuAction {
9+
/// Launch the link
10+
launch,
11+
12+
/// Copy to clipboard
13+
copy,
14+
15+
/// Remove link style attribute
16+
remove,
17+
18+
/// No-op
19+
none,
20+
}
21+
22+
/// Used internally by widget layer.
23+
typedef LinkActionPicker = Future<LinkMenuAction> Function(Node linkNode);
24+
25+
typedef LinkActionPickerDelegate = Future<LinkMenuAction> Function(
26+
BuildContext context, String link);
27+
28+
Future<LinkMenuAction> defaultLinkActionPickerDelegate(
29+
BuildContext context, String link) async {
30+
switch (defaultTargetPlatform) {
31+
case TargetPlatform.iOS:
32+
return _showCupertinoLinkMenu(context, link);
33+
case TargetPlatform.android:
34+
return _showMaterialMenu(context, link);
35+
default:
36+
assert(
37+
false,
38+
'defaultShowLinkActionsMenu not supposed to '
39+
'be invoked for $defaultTargetPlatform');
40+
return LinkMenuAction.none;
41+
}
42+
}
43+
44+
Future<LinkMenuAction> _showCupertinoLinkMenu(
45+
BuildContext context, String link) async {
46+
final result = await showCupertinoModalPopup<LinkMenuAction>(
47+
context: context,
48+
builder: (ctx) {
49+
return CupertinoActionSheet(
50+
title: Text(link),
51+
actions: [
52+
_CupertinoAction(
53+
title: 'Open',
54+
icon: Icons.language_sharp,
55+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch),
56+
),
57+
_CupertinoAction(
58+
title: 'Copy',
59+
icon: Icons.copy_sharp,
60+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy),
61+
),
62+
_CupertinoAction(
63+
title: 'Remove',
64+
icon: Icons.link_off_sharp,
65+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove),
66+
),
67+
],
68+
);
69+
},
70+
);
71+
return result ?? LinkMenuAction.none;
72+
}
73+
74+
class _CupertinoAction extends StatelessWidget {
75+
const _CupertinoAction({
76+
required this.title,
77+
required this.icon,
78+
required this.onPressed,
79+
Key? key,
80+
}) : super(key: key);
81+
82+
final String title;
83+
final IconData icon;
84+
final VoidCallback onPressed;
85+
86+
@override
87+
Widget build(BuildContext context) {
88+
final theme = Theme.of(context);
89+
return CupertinoActionSheetAction(
90+
onPressed: onPressed,
91+
child: Padding(
92+
padding: const EdgeInsets.symmetric(horizontal: 8),
93+
child: Row(
94+
children: [
95+
Expanded(
96+
child: Text(
97+
title,
98+
textAlign: TextAlign.start,
99+
style: TextStyle(color: theme.colorScheme.onSurface),
100+
),
101+
),
102+
Icon(
103+
icon,
104+
size: theme.iconTheme.size,
105+
color: theme.colorScheme.onSurface.withOpacity(0.75),
106+
)
107+
],
108+
),
109+
),
110+
);
111+
}
112+
}
113+
114+
Future<LinkMenuAction> _showMaterialMenu(
115+
BuildContext context, String link) async {
116+
final result = await showModalBottomSheet<LinkMenuAction>(
117+
context: context,
118+
builder: (ctx) {
119+
return Column(
120+
mainAxisSize: MainAxisSize.min,
121+
children: [
122+
_MaterialAction(
123+
title: 'Open',
124+
icon: Icons.language_sharp,
125+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch),
126+
),
127+
_MaterialAction(
128+
title: 'Copy',
129+
icon: Icons.copy_sharp,
130+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy),
131+
),
132+
_MaterialAction(
133+
title: 'Remove',
134+
icon: Icons.link_off_sharp,
135+
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove),
136+
),
137+
],
138+
);
139+
},
140+
);
141+
142+
return result ?? LinkMenuAction.none;
143+
}
144+
145+
class _MaterialAction extends StatelessWidget {
146+
const _MaterialAction({
147+
required this.title,
148+
required this.icon,
149+
required this.onPressed,
150+
Key? key,
151+
}) : super(key: key);
152+
153+
final String title;
154+
final IconData icon;
155+
final VoidCallback onPressed;
156+
157+
@override
158+
Widget build(BuildContext context) {
159+
final theme = Theme.of(context);
160+
return ListTile(
161+
leading: Icon(
162+
icon,
163+
size: theme.iconTheme.size,
164+
color: theme.colorScheme.onSurface.withOpacity(0.75),
165+
),
166+
title: Text(title),
167+
onTap: onPressed,
168+
);
169+
}
170+
}

0 commit comments

Comments
 (0)