Skip to content

Commit

Permalink
Launch link improvements (#581)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pulyaevskiy committed Dec 23, 2021
1 parent baeee13 commit ef283f7
Show file tree
Hide file tree
Showing 8 changed files with 491 additions and 39 deletions.
5 changes: 4 additions & 1 deletion packages/zefyr/CHANGELOG.md
@@ -1,6 +1,9 @@
## 1.0.0-rc.3

* Added keyboard shortcuts for bold, italic and underline styles.
* Added keyboard shortcuts for bold, italic and underline styles. ([#580](https://github.com/memspace/zefyr/pull/580))
* Launch URL improvements: allow launching links in editing mode ([#581](https://github.com/memspace/zefyr/pull/581))
- 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.0.0-rc.2

Expand Down
9 changes: 9 additions & 0 deletions packages/zefyr/example/lib/src/read_only_view.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zefyr/zefyr.dart';

import 'scaffold.dart';
Expand Down Expand Up @@ -44,11 +45,19 @@ class _ReadOnlyViewState extends State<ReadOnlyView> {
readOnly: !_edit,
showCursor: _edit,
padding: const EdgeInsets.symmetric(horizontal: 8),
onLaunchUrl: _launchUrl,
),
),
);
}

void _launchUrl(String url) async {
final result = await canLaunch(url);
if (result) {
await launch(url);
}
}

void _toggleEdit() {
setState(() {
_edit = !_edit;
Expand Down
11 changes: 10 additions & 1 deletion packages/zefyr/lib/src/widgets/editable_text_block.dart
Expand Up @@ -7,6 +7,7 @@ import 'controller.dart';
import 'cursor.dart';
import 'editable_text_line.dart';
import 'editor.dart';
import 'link.dart';
import 'text_line.dart';
import 'theme.dart';

Expand All @@ -20,8 +21,10 @@ class EditableTextBlock extends StatelessWidget {
final Color selectionColor;
final bool enableInteractiveSelection;
final bool hasFocus;
final EdgeInsets? contentPadding;
final ZefyrEmbedBuilder embedBuilder;
final LinkActionPicker linkActionPicker;
final ValueChanged<String?>? onLaunchUrl;
final EdgeInsets? contentPadding;

const EditableTextBlock({
Key? key,
Expand All @@ -35,6 +38,8 @@ class EditableTextBlock extends StatelessWidget {
required this.enableInteractiveSelection,
required this.hasFocus,
required this.embedBuilder,
required this.linkActionPicker,
this.onLaunchUrl,
this.contentPadding,
}) : super(key: key);

Expand Down Expand Up @@ -70,7 +75,11 @@ class EditableTextBlock extends StatelessWidget {
devicePixelRatio: MediaQuery.of(context).devicePixelRatio,
body: TextLine(
node: line,
readOnly: readOnly,
controller: controller,
embedBuilder: embedBuilder,
linkActionPicker: linkActionPicker,
onLaunchUrl: onLaunchUrl,
),
cursorController: cursorController,
selection: selection,
Expand Down
67 changes: 40 additions & 27 deletions packages/zefyr/lib/src/widgets/editor.dart
Expand Up @@ -8,8 +8,8 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:notus/notus.dart';
import 'package:zefyr/util.dart';

import '../../util.dart';
import '../rendering/editor.dart';
import 'baseline_proxy.dart';
import 'controller.dart';
Expand All @@ -18,6 +18,8 @@ import 'editable_text_block.dart';
import 'editable_text_line.dart';
import 'editor_input_client_mixin.dart';
import 'editor_selection_delegate_mixin.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'shortcuts.dart';
import 'single_child_scroll_view.dart';
import 'text_line.dart';
Expand Down Expand Up @@ -178,6 +180,21 @@ class ZefyrEditor extends StatefulWidget {
/// Defaults to [defaultZefyrEmbedBuilder].
final ZefyrEmbedBuilder embedBuilder;

/// Delegate function responsible for showing menu with link actions on
/// mobile platforms (iOS, Android).
///
/// The menu is triggered in editing mode ([readOnly] is set to `false`)
/// when the user long-presses a link-styled text segment.
///
/// Zefyr provides default implementation which can be overridden by this
/// field to customize the user experience.
///
/// By default on iOS the menu is displayed with [showCupertinoModalPopup]
/// which constructs an instance of [CupertinoActionSheet]. For Android,
/// the menu is displayed with [showModalBottomSheet] and a list of
/// Material [ListTile]s.
final LinkActionPickerDelegate linkActionPickerDelegate;

const ZefyrEditor({
Key? key,
required this.controller,
Expand All @@ -197,6 +214,7 @@ class ZefyrEditor extends StatefulWidget {
this.scrollPhysics,
this.onLaunchUrl,
this.embedBuilder = defaultZefyrEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
}) : super(key: key);

@override
Expand Down Expand Up @@ -298,6 +316,7 @@ class _ZefyrEditorState extends State<ZefyrEditor>
scrollPhysics: widget.scrollPhysics,
onLaunchUrl: widget.onLaunchUrl,
embedBuilder: widget.embedBuilder,
linkActionPickerDelegate: widget.linkActionPickerDelegate,
// encapsulated fields below
cursorStyle: CursorStyle(
color: cursorColor,
Expand Down Expand Up @@ -371,26 +390,6 @@ class _ZefyrEditorSelectionGestureDetectorBuilder
}
}

void _launchUrlIfNeeded(TapUpDetails details) {
final pos = renderEditor!.getPositionForOffset(details.globalPosition);
final result = editor!.widget.controller.document.lookupLine(pos.offset);
if (result.node == null) return;
final line = result.node as LineNode;
final segmentResult = line.lookup(result.offset);
if (segmentResult.node == null) return;
final segment = segmentResult.node as LeafNode;
if (segment.style.contains(NotusAttribute.link) &&
editor!.widget.onLaunchUrl != null) {
if (editor!.widget.readOnly) {
editor!
.widget.onLaunchUrl!(segment.style.get(NotusAttribute.link)!.value);
} else {
// TODO: Implement a toolbar to display the URL and allow to launch it.
// editor.showToolbar();
}
}
}

bool isShiftClick(PointerDeviceKind deviceKind) {
final pressed = RawKeyboard.instance.keysPressed;
return deviceKind == PointerDeviceKind.mouse &&
Expand All @@ -402,9 +401,6 @@ class _ZefyrEditorSelectionGestureDetectorBuilder
void onSingleTapUp(TapUpDetails details) {
editor!.hideToolbar();

// TODO: Explore if we can forward tap up events to the TextSpan gesture detector
_launchUrlIfNeeded(details);

if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
Expand Down Expand Up @@ -496,6 +492,7 @@ class RawEditor extends StatefulWidget {
this.showSelectionHandles = false,
this.selectionControls,
this.embedBuilder = defaultZefyrEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
}) : assert(scrollable || scrollController != null),
assert(maxHeight == null || maxHeight > 0),
assert(minHeight == null || minHeight >= 0),
Expand Down Expand Up @@ -641,6 +638,8 @@ class RawEditor extends StatefulWidget {
/// Defaults to [defaultZefyrEmbedBuilder].
final ZefyrEmbedBuilder embedBuilder;

final LinkActionPickerDelegate linkActionPickerDelegate;

bool get selectionEnabled => enableInteractiveSelection;

@override
Expand Down Expand Up @@ -1182,6 +1181,12 @@ class RawEditorState extends EditorState
});
}

Future<LinkMenuAction> _linkActionPicker(Node linkNode) async {
final link =
(linkNode as StyledNode).style.get(NotusAttribute.link)!.value!;
return widget.linkActionPickerDelegate(context, link);
}

@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
Expand Down Expand Up @@ -1256,9 +1261,11 @@ class RawEditorState extends EditorState
data: _themeData,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: Container(
constraints: constraints,
child: child,
child: ZefyrKeyboardListener(
child: Container(
constraints: constraints,
child: child,
),
),
),
);
Expand All @@ -1280,7 +1287,11 @@ class RawEditorState extends EditorState
enableInteractiveSelection: widget.enableInteractiveSelection,
body: TextLine(
node: node,
readOnly: widget.readOnly,
controller: widget.controller,
embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
),
hasFocus: _hasFocus,
devicePixelRatio: MediaQuery.of(context).devicePixelRatio,
Expand All @@ -1304,6 +1315,8 @@ class RawEditorState extends EditorState
? const EdgeInsets.all(16.0)
: null,
embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
),
));
} else {
Expand Down
87 changes: 87 additions & 0 deletions packages/zefyr/lib/src/widgets/keyboard_listener.dart
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class ZefyrPressedKeys extends ChangeNotifier {
static ZefyrPressedKeys of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<_ZefyrPressedKeysAccess>();
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 ZefyrKeyboardListener extends StatefulWidget {
final Widget child;
const ZefyrKeyboardListener({Key? key, required this.child})
: super(key: key);

@override
ZefyrKeyboardListenerState createState() => ZefyrKeyboardListenerState();
}

class ZefyrKeyboardListenerState extends State<ZefyrKeyboardListener> {
final ZefyrPressedKeys _pressedKeys = ZefyrPressedKeys();

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 _ZefyrPressedKeysAccess(
pressedKeys: _pressedKeys,
child: widget.child,
);
}
}

class _ZefyrPressedKeysAccess extends InheritedWidget {
final ZefyrPressedKeys pressedKeys;
const _ZefyrPressedKeysAccess({
Key? key,
required this.pressedKeys,
required Widget child,
}) : super(key: key, child: child);

@override
bool updateShouldNotify(covariant _ZefyrPressedKeysAccess oldWidget) {
return oldWidget.pressedKeys != pressedKeys;
}
}

0 comments on commit ef283f7

Please sign in to comment.