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.
pull/555/head
X Code 3 years ago
parent c8b50d9e80
commit 2080aab6a9
  1. 38
      lib/src/widgets/editor.dart
  2. 89
      lib/src/widgets/keyboard_listener.dart
  3. 170
      lib/src/widgets/link.dart
  4. 40
      lib/src/widgets/raw_editor.dart
  5. 15
      lib/src/widgets/text_block.dart
  6. 242
      lib/src/widgets/text_line.dart

@ -9,9 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/embed.dart';
@ -25,6 +23,7 @@ import 'default_styles.dart';
import 'delegate.dart';
import 'float_cursor.dart';
import 'image.dart';
import 'link.dart';
import 'raw_editor.dart';
import 'text_selection.dart';
import 'video_app.dart';
@ -246,6 +245,7 @@ class QuillEditor extends StatefulWidget {
this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd,
this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder,
this.floatingCursorDisabled = false,
Key? key});
@ -312,6 +312,21 @@ class QuillEditor extends StatefulWidget {
final EmbedBuilder embedBuilder;
final CustomStyleBuilder? customStyleBuilder;
/// 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.
///
/// FlutterQuill 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;
final bool floatingCursorDisabled;
@override
@ -415,6 +430,7 @@ class _QuillEditorState extends State<QuillEditor>
enableInteractiveSelection: widget.enableInteractiveSelection,
scrollPhysics: widget.scrollPhysics,
embedBuilder: widget.embedBuilder,
linkActionPickerDelegate: widget.linkActionPickerDelegate,
customStyleBuilder: widget.customStyleBuilder,
floatingCursorDisabled: widget.floatingCursorDisabled,
);
@ -520,20 +536,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
return false;
}
final segment = segmentResult.node as leaf.Leaf;
if (segment.style.containsKey(Attribute.link.key)) {
var launchUrl = getEditor()!.widget.onLaunchUrl;
launchUrl ??= _launchUrl;
String? link = segment.style.attributes[Attribute.link.key]!.value;
if (getEditor()!.widget.readOnly && link != null) {
link = link.trim();
if (!linkPrefixes
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
link = 'https://$link';
}
launchUrl(link);
}
return false;
}
if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
final blockEmbed = segment.value as BlockEmbed;
if (blockEmbed.type == 'image') {
@ -557,10 +559,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
return false;
}
Future<void> _launchUrl(String url) async {
await launch(url);
}
@override
void onTapDown(TapDownDetails details) {
if (_state.widget.onTapDown != null) {

@ -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,
);
}
}

@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:tuple/tuple.dart';
import '../../models/documents/nodes/node.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
@ -21,6 +22,8 @@ import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart';
import 'quill_single_child_scroll_view.dart';
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
@ -62,6 +65,7 @@ class RawEditor extends StatefulWidget {
this.enableInteractiveSelection = true,
this.scrollPhysics,
this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder,
this.floatingCursorDisabled = false})
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
@ -76,9 +80,25 @@ class RawEditor extends StatefulWidget {
final bool scrollable;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
/// Whether the text can be changed.
///
/// When this is set to true, the text cannot be modified
/// by any shortcut or keyboard operation. The text is still selectable.
///
/// Defaults to false. Must not be null.
final bool readOnly;
final String? placeholder;
/// Callback which is triggered when the user wants to open a URL from
/// a link in the document.
final ValueChanged<String>? onLaunchUrl;
/// Configuration of toolbar options.
///
/// By default, all options are enabled. If [readOnly] is true,
/// paste and cut will be disabled regardless.
final ToolbarOptions toolbarOptions;
final bool showSelectionHandles;
final bool showCursor;
@ -95,6 +115,7 @@ class RawEditor extends StatefulWidget {
final bool enableInteractiveSelection;
final ScrollPhysics? scrollPhysics;
final EmbedBuilder embedBuilder;
final LinkActionPickerDelegate linkActionPickerDelegate;
final CustomStyleBuilder? customStyleBuilder;
final bool floatingCursorDisabled;
@ -218,9 +239,11 @@ class RawEditorState extends EditorState
data: _styles!,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: Container(
constraints: constraints,
child: child,
child: QuillKeyboardListener(
child: Container(
constraints: constraints,
child: child,
),
),
),
);
@ -270,6 +293,7 @@ class RawEditorState extends EditorState
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
block: node,
controller: widget.controller,
textDirection: _textDirection,
scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
@ -282,6 +306,8 @@ class RawEditorState extends EditorState
? const EdgeInsets.all(16)
: null,
embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
cursorCont: _cursorCont,
indentLevelCounts: indentLevelCounts,
onCheckboxTap: _handleCheckboxTap,
@ -304,6 +330,9 @@ class RawEditorState extends EditorState
customStyleBuilder: widget.customStyleBuilder,
styles: _styles!,
readOnly: widget.readOnly,
controller: widget.controller,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
);
final editableTextLine = EditableTextLine(
node,
@ -592,6 +621,11 @@ class RawEditorState extends EditorState
});
}
Future<LinkMenuAction> _linkActionPicker(Node linkNode) async {
final link = linkNode.style.attributes[Attribute.link.key]!.value!;
return widget.linkActionPickerDelegate(context, link);
}
bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() {

@ -2,15 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../../flutter_quill.dart';
import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/line.dart';
import '../widgets/style_widgets/style_widgets.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'link.dart';
import 'text_line.dart';
import 'text_selection.dart';
@ -49,6 +47,7 @@ const List<String> romanNumbers = [
class EditableTextBlock extends StatelessWidget {
const EditableTextBlock(
{required this.block,
required this.controller,
required this.textDirection,
required this.scrollBottomInset,
required this.verticalSpacing,
@ -59,14 +58,17 @@ class EditableTextBlock extends StatelessWidget {
required this.hasFocus,
required this.contentPadding,
required this.embedBuilder,
required this.linkActionPicker,
required this.cursorCont,
required this.indentLevelCounts,
required this.onCheckboxTap,
required this.readOnly,
this.onLaunchUrl,
this.customStyleBuilder,
Key? key});
final Block block;
final QuillController controller;
final TextDirection textDirection;
final double scrollBottomInset;
final Tuple2 verticalSpacing;
@ -77,6 +79,8 @@ class EditableTextBlock extends StatelessWidget {
final bool hasFocus;
final EdgeInsets? contentPadding;
final EmbedBuilder embedBuilder;
final LinkActionPicker linkActionPicker;
final ValueChanged<String>? onLaunchUrl;
final CustomStyleBuilder? customStyleBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
@ -128,6 +132,9 @@ class EditableTextBlock extends StatelessWidget {
customStyleBuilder: customStyleBuilder,
styles: styles!,
readOnly: readOnly,
controller: controller,
linkActionPicker: linkActionPicker,
onLaunchUrl: onLaunchUrl,
),
_getIndentWidth(),
_getSpacingForLine(line, index, count, defaultStyles),

@ -2,31 +2,37 @@ import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart';
import '../../flutter_quill.dart';
import '../models/documents/nodes/container.dart' as container;
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/leaf.dart';
import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart';
import '../utils/color.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart';
import 'text_selection.dart';
class TextLine extends StatelessWidget {
class TextLine extends StatefulWidget {
const TextLine({
required this.line,
required this.embedBuilder,
required this.styles,
required this.readOnly,
required this.controller,
required this.onLaunchUrl,
required this.linkActionPicker,
this.textDirection,
this.customStyleBuilder,
Key? key,
@ -37,23 +43,109 @@ class TextLine extends StatelessWidget {
final EmbedBuilder embedBuilder;
final DefaultStyles styles;
final bool readOnly;
final QuillController controller;
final CustomStyleBuilder? customStyleBuilder;
final ValueChanged<String>? onLaunchUrl;
final LinkActionPicker linkActionPicker;
@override
State<TextLine> createState() => _TextLineState();
}
class _TextLineState extends State<TextLine> {
bool _metaOrControlPressed = false;
UniqueKey _richTextKey = UniqueKey();
final _linkRecognizers = <Node, GestureRecognizer>{};
QuillPressedKeys? _pressedKeys;
void _pressedKeysChanged() {
final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed;
if (_metaOrControlPressed != newValue) {
setState(() {
_metaOrControlPressed = newValue;
_richTextKey = UniqueKey();
});
}
}
bool get isDesktop => {
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows
}.contains(defaultTargetPlatform);
bool get canLaunchLinks {
// In readOnly mode users can launch links
// by simply tapping (clicking) on them
if (widget.readOnly) return true;
// In editing mode it depends on the platform:
// Desktop platforms (macos, linux, windows):
// only allow Meta(Control)+Click combinations
if (isDesktop) {
return _metaOrControlPressed;
}
// Mobile platforms (ios, android): always allow but we install a
// long-press handler instead of a tap one. LongPress is followed by a
// context menu with actions.
return true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_pressedKeys == null) {
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
} else {
_pressedKeys!.removeListener(_pressedKeysChanged);
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
}
}
@override
void didUpdateWidget(covariant TextLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.readOnly != widget.readOnly) {
_richTextKey = UniqueKey();
_linkRecognizers
..forEach((key, value) {
value.dispose();
})
..clear();
}
}
@override
void dispose() {
_pressedKeys?.removeListener(_pressedKeysChanged);
_linkRecognizers
..forEach((key, value) => value.dispose())
..clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
if (line.hasEmbed && line.childCount == 1) {
if (widget.line.hasEmbed && widget.line.childCount == 1) {
// For video, it is always single child
final embed = line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed, readOnly));
final embed = widget.line.children.single as Embed;
return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly));
}
final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign();
final child = RichText(
key: _richTextKey,
text: textSpan,
textAlign: textAlign,
textDirection: textDirection,
textDirection: widget.textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
@ -61,7 +153,7 @@ class TextLine extends StatelessWidget {
child,
textSpan.style!,
textAlign,
textDirection!,
widget.textDirection!,
1,
Localizations.localeOf(context),
strutStyle,
@ -70,23 +162,25 @@ class TextLine extends StatelessWidget {
}
InlineSpan _getTextSpanForWholeLine(BuildContext context) {
final lineStyle = _getLineStyle(styles);
if (!line.hasEmbed) {
return _buildTextSpan(styles, line.children, lineStyle);
final lineStyle = _getLineStyle(widget.styles);
if (!widget.line.hasEmbed) {
return _buildTextSpan(widget.styles, widget.line.children, lineStyle);
}
// The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>();
for (final child in line.children) {
for (final child in widget.line.children) {
if (child is Embed) {
if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle));
textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>();
}
// Here it should be image
final embed = WidgetSpan(
child: EmbedProxy(embedBuilder(context, child, readOnly)));
child: EmbedProxy(
widget.embedBuilder(context, child, widget.readOnly)));
textSpanChildren.add(embed);
continue;
}
@ -96,14 +190,14 @@ class TextLine extends StatelessWidget {
}
if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle));
textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
}
return TextSpan(style: lineStyle, children: textSpanChildren);
}
TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key];
final alignment = widget.line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.start;
} else if (alignment == Attribute.centerAlignment) {
@ -119,7 +213,8 @@ class TextLine extends StatelessWidget {
TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes,
TextStyle lineStyle) {
final children = nodes
.map((node) => _getTextSpanFromNode(defaultStyles, node, line.style))
.map((node) =>
_getTextSpanFromNode(defaultStyles, node, widget.line.style))
.toList(growable: false);
return TextSpan(children: children, style: lineStyle);
@ -128,11 +223,11 @@ class TextLine extends StatelessWidget {
TextStyle _getLineStyle(DefaultStyles defaultStyles) {
var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) {
if (widget.line.style.containsKey(Attribute.placeholder.key)) {
return defaultStyles.placeHolder!.style;
}
final header = line.style.attributes[Attribute.header.key];
final header = widget.line.style.attributes[Attribute.header.key];
final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style,
@ -143,7 +238,7 @@ class TextLine extends StatelessWidget {
// Only retrieve exclusive block format for the line style purpose
Attribute? block;
line.style.getBlocksExceptHeader().forEach((key, value) {
widget.line.style.getBlocksExceptHeader().forEach((key, value) {
if (Attribute.exclusiveBlockKeys.contains(key)) {
block = value;
}
@ -159,21 +254,21 @@ class TextLine extends StatelessWidget {
}
textStyle = textStyle.merge(toMerge);
textStyle = _applyCustomAttributes(textStyle, line.style.attributes);
textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes);
return textStyle;
}
TextStyle _applyCustomAttributes(
TextStyle textStyle, Map<String, Attribute> attributes) {
if (customStyleBuilder == null) {
if (widget.customStyleBuilder == null) {
return textStyle;
}
attributes.keys.forEach((key) {
final attr = attributes[key];
if (attr != null) {
/// Custom Attribute
final customAttr = customStyleBuilder!.call(attr);
final customAttr = widget.customStyleBuilder!.call(attr);
textStyle = textStyle.merge(customAttr);
}
});
@ -184,6 +279,7 @@ class TextLine extends StatelessWidget {
DefaultStyles defaultStyles, Node node, Style lineStyle) {
final textNode = node as leaf.Text;
final nodeStyle = textNode.style;
final isLink = nodeStyle.containsKey(Attribute.link.key);
var res = const TextStyle(); // This is inline text style
final color = textNode.style.attributes[Attribute.color.key];
var hasLink = false;
@ -268,14 +364,108 @@ class TextLine extends StatelessWidget {
}
res = _applyCustomAttributes(res, textNode.style.attributes);
if (hasLink && readOnly) {
if (hasLink && widget.readOnly) {
return TextSpan(
text: textNode.value,
style: res,
mouseCursor: SystemMouseCursors.click,
);
}
return TextSpan(text: textNode.value, style: res);
return TextSpan(
text: textNode.value,
style: res,
recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null,
mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null,
);
}
GestureRecognizer _getRecognizer(Node segment) {
if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!;
}
if (isDesktop || widget.readOnly) {
_linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment);
} else {
_linkRecognizers[segment] = LongPressGestureRecognizer()
..onLongPress = () => _longPressLink(segment);
}
return _linkRecognizers[segment]!;
}
Future<void> _launchUrl(String url) async {
await launch(url);
}
void _tapNodeLink(Node node) {
final link = node.style.attributes[Attribute.link.key]!.value;
_tapLink(link);
}
void _tapLink(String? link) {
if (widget.readOnly || link == null) {
return;
}
var launchUrl = widget.onLaunchUrl;
launchUrl ??= _launchUrl;
link = link.trim();
if (!linkPrefixes
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
link = 'https://$link';
}
launchUrl(link);
}
Future<void> _longPressLink(Node node) async {
final link = node.style.attributes[Attribute.link.key]!.value!;
final action = await widget.linkActionPicker(node);
switch (action) {
case LinkMenuAction.launch:
_tapLink(link);
break;
case LinkMenuAction.copy:
// ignore: unawaited_futures
Clipboard.setData(ClipboardData(text: link));
break;
case LinkMenuAction.remove:
final range = _getLinkRange(node);
widget.controller
.formatText(range.start, range.end - range.start, Attribute.link);
break;
case LinkMenuAction.none:
break;
}
}
TextRange _getLinkRange(Node node) {
var start = node.documentOffset;
var length = node.length;
var prev = node.previous;
final linkAttr = node.style.attributes[Attribute.link.key]!;
while (prev != null) {
if (prev.style.attributes[Attribute.link.key] == linkAttr) {
start = prev.documentOffset;
length += prev.length;
prev = prev.previous;
} else {
break;
}
}
var next = node.next;
while (next != null) {
if (next.style.attributes[Attribute.link.key] == linkAttr) {
length += next.length;
next = next.next;
} else {
break;
}
}
return TextRange(start: start, end: start + length);
}
TextStyle _merge(TextStyle a, TextStyle b) {

Loading…
Cancel
Save