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