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.pull/555/head
parent
c8b50d9e80
commit
2080aab6a9
6 changed files with 541 additions and 53 deletions
@ -0,0 +1,89 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter/services.dart'; |
||||||
|
|
||||||
|
class QuillPressedKeys extends ChangeNotifier { |
||||||
|
static QuillPressedKeys of(BuildContext context) { |
||||||
|
final widget = |
||||||
|
context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>(); |
||||||
|
return widget!.pressedKeys; |
||||||
|
} |
||||||
|
|
||||||
|
bool _metaPressed = false; |
||||||
|
bool _controlPressed = false; |
||||||
|
|
||||||
|
/// Whether meta key is currently pressed. |
||||||
|
bool get metaPressed => _metaPressed; |
||||||
|
|
||||||
|
/// Whether control key is currently pressed. |
||||||
|
bool get controlPressed => _controlPressed; |
||||||
|
|
||||||
|
void _updatePressedKeys(Set<LogicalKeyboardKey> pressedKeys) { |
||||||
|
final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) || |
||||||
|
pressedKeys.contains(LogicalKeyboardKey.metaRight); |
||||||
|
final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) || |
||||||
|
pressedKeys.contains(LogicalKeyboardKey.controlRight); |
||||||
|
if (_metaPressed != meta || _controlPressed != control) { |
||||||
|
_metaPressed = meta; |
||||||
|
_controlPressed = control; |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class QuillKeyboardListener extends StatefulWidget { |
||||||
|
const QuillKeyboardListener({required this.child, Key? key}) |
||||||
|
: super(key: key); |
||||||
|
|
||||||
|
final Widget child; |
||||||
|
|
||||||
|
@override |
||||||
|
QuillKeyboardListenerState createState() => QuillKeyboardListenerState(); |
||||||
|
} |
||||||
|
|
||||||
|
class QuillKeyboardListenerState extends State<QuillKeyboardListener> { |
||||||
|
final QuillPressedKeys _pressedKeys = QuillPressedKeys(); |
||||||
|
|
||||||
|
bool _keyEvent(KeyEvent event) { |
||||||
|
_pressedKeys |
||||||
|
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
HardwareKeyboard.instance.addHandler(_keyEvent); |
||||||
|
_pressedKeys |
||||||
|
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
HardwareKeyboard.instance.removeHandler(_keyEvent); |
||||||
|
_pressedKeys.dispose(); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return _QuillPressedKeysAccess( |
||||||
|
pressedKeys: _pressedKeys, |
||||||
|
child: widget.child, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class _QuillPressedKeysAccess extends InheritedWidget { |
||||||
|
const _QuillPressedKeysAccess({ |
||||||
|
required this.pressedKeys, |
||||||
|
required Widget child, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key, child: child); |
||||||
|
|
||||||
|
final QuillPressedKeys pressedKeys; |
||||||
|
|
||||||
|
@override |
||||||
|
bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) { |
||||||
|
return oldWidget.pressedKeys != pressedKeys; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,170 @@ |
|||||||
|
import 'package:flutter/cupertino.dart'; |
||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
import '../../models/documents/nodes/node.dart'; |
||||||
|
|
||||||
|
/// List of possible actions returned from [LinkActionPickerDelegate]. |
||||||
|
enum LinkMenuAction { |
||||||
|
/// Launch the link |
||||||
|
launch, |
||||||
|
|
||||||
|
/// Copy to clipboard |
||||||
|
copy, |
||||||
|
|
||||||
|
/// Remove link style attribute |
||||||
|
remove, |
||||||
|
|
||||||
|
/// No-op |
||||||
|
none, |
||||||
|
} |
||||||
|
|
||||||
|
/// Used internally by widget layer. |
||||||
|
typedef LinkActionPicker = Future<LinkMenuAction> Function(Node linkNode); |
||||||
|
|
||||||
|
typedef LinkActionPickerDelegate = Future<LinkMenuAction> Function( |
||||||
|
BuildContext context, String link); |
||||||
|
|
||||||
|
Future<LinkMenuAction> defaultLinkActionPickerDelegate( |
||||||
|
BuildContext context, String link) async { |
||||||
|
switch (defaultTargetPlatform) { |
||||||
|
case TargetPlatform.iOS: |
||||||
|
return _showCupertinoLinkMenu(context, link); |
||||||
|
case TargetPlatform.android: |
||||||
|
return _showMaterialMenu(context, link); |
||||||
|
default: |
||||||
|
assert( |
||||||
|
false, |
||||||
|
'defaultShowLinkActionsMenu not supposed to ' |
||||||
|
'be invoked for $defaultTargetPlatform'); |
||||||
|
return LinkMenuAction.none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<LinkMenuAction> _showCupertinoLinkMenu( |
||||||
|
BuildContext context, String link) async { |
||||||
|
final result = await showCupertinoModalPopup<LinkMenuAction>( |
||||||
|
context: context, |
||||||
|
builder: (ctx) { |
||||||
|
return CupertinoActionSheet( |
||||||
|
title: Text(link), |
||||||
|
actions: [ |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Open', |
||||||
|
icon: Icons.language_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), |
||||||
|
), |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Copy', |
||||||
|
icon: Icons.copy_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), |
||||||
|
), |
||||||
|
_CupertinoAction( |
||||||
|
title: 'Remove', |
||||||
|
icon: Icons.link_off_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
return result ?? LinkMenuAction.none; |
||||||
|
} |
||||||
|
|
||||||
|
class _CupertinoAction extends StatelessWidget { |
||||||
|
const _CupertinoAction({ |
||||||
|
required this.title, |
||||||
|
required this.icon, |
||||||
|
required this.onPressed, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final String title; |
||||||
|
final IconData icon; |
||||||
|
final VoidCallback onPressed; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final theme = Theme.of(context); |
||||||
|
return CupertinoActionSheetAction( |
||||||
|
onPressed: onPressed, |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8), |
||||||
|
child: Row( |
||||||
|
children: [ |
||||||
|
Expanded( |
||||||
|
child: Text( |
||||||
|
title, |
||||||
|
textAlign: TextAlign.start, |
||||||
|
style: TextStyle(color: theme.colorScheme.onSurface), |
||||||
|
), |
||||||
|
), |
||||||
|
Icon( |
||||||
|
icon, |
||||||
|
size: theme.iconTheme.size, |
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.75), |
||||||
|
) |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<LinkMenuAction> _showMaterialMenu( |
||||||
|
BuildContext context, String link) async { |
||||||
|
final result = await showModalBottomSheet<LinkMenuAction>( |
||||||
|
context: context, |
||||||
|
builder: (ctx) { |
||||||
|
return Column( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
_MaterialAction( |
||||||
|
title: 'Open', |
||||||
|
icon: Icons.language_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch), |
||||||
|
), |
||||||
|
_MaterialAction( |
||||||
|
title: 'Copy', |
||||||
|
icon: Icons.copy_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy), |
||||||
|
), |
||||||
|
_MaterialAction( |
||||||
|
title: 'Remove', |
||||||
|
icon: Icons.link_off_sharp, |
||||||
|
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
return result ?? LinkMenuAction.none; |
||||||
|
} |
||||||
|
|
||||||
|
class _MaterialAction extends StatelessWidget { |
||||||
|
const _MaterialAction({ |
||||||
|
required this.title, |
||||||
|
required this.icon, |
||||||
|
required this.onPressed, |
||||||
|
Key? key, |
||||||
|
}) : super(key: key); |
||||||
|
|
||||||
|
final String title; |
||||||
|
final IconData icon; |
||||||
|
final VoidCallback onPressed; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final theme = Theme.of(context); |
||||||
|
return ListTile( |
||||||
|
leading: Icon( |
||||||
|
icon, |
||||||
|
size: theme.iconTheme.size, |
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.75), |
||||||
|
), |
||||||
|
title: Text(title), |
||||||
|
onTap: onPressed, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue