diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index a6fb480d..b55047ae 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.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 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 _launchUrl(String url) async { - await launch(url); - } - @override void onTapDown(TapDownDetails details) { if (_state.widget.onTapDown != null) { diff --git a/lib/src/widgets/keyboard_listener.dart b/lib/src/widgets/keyboard_listener.dart new file mode 100644 index 00000000..47cb9555 --- /dev/null +++ b/lib/src/widgets/keyboard_listener.dart @@ -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 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 { + 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; + } +} diff --git a/lib/src/widgets/link.dart b/lib/src/widgets/link.dart new file mode 100644 index 00000000..87378ca5 --- /dev/null +++ b/lib/src/widgets/link.dart @@ -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 Function(Node linkNode); + +typedef LinkActionPickerDelegate = Future Function( + BuildContext context, String link); + +Future 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 _showCupertinoLinkMenu( + BuildContext context, String link) async { + final result = await showCupertinoModalPopup( + 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 _showMaterialMenu( + BuildContext context, String link) async { + final result = await showModalBottomSheet( + 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, + ); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index e8cf766d..506020a1 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -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? 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 _linkActionPicker(Node linkNode) async { + final link = linkNode.style.attributes[Attribute.link.key]!.value!; + return widget.linkActionPickerDelegate(context, link); + } + bool _showCaretOnScreenScheduled = false; void _showCaretOnScreen() { diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index fcfaa195..819ebe07 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -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 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? onLaunchUrl; final CustomStyleBuilder? customStyleBuilder; final CursorCont cursorCont; final Map 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), diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index a9e2dbc5..0b2e8b3e 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -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? onLaunchUrl; + final LinkActionPicker linkActionPicker; + + @override + State createState() => _TextLineState(); +} + +class _TextLineState extends State { + bool _metaOrControlPressed = false; + + UniqueKey _richTextKey = UniqueKey(); + + final _linkRecognizers = {}; + + 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 = []; var textNodes = LinkedList(); - 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(); } // 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 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.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 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 _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 _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) {