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/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:string_validator/string_validator.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/document.dart';
import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/embed.dart'; import '../models/documents/nodes/embed.dart';
@ -25,6 +23,7 @@ import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'float_cursor.dart'; import 'float_cursor.dart';
import 'image.dart'; import 'image.dart';
import 'link.dart';
import 'raw_editor.dart'; import 'raw_editor.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'video_app.dart'; import 'video_app.dart';
@ -246,6 +245,7 @@ class QuillEditor extends StatefulWidget {
this.onSingleLongTapMoveUpdate, this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd, this.onSingleLongTapEnd,
this.embedBuilder = defaultEmbedBuilder, this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.floatingCursorDisabled = false, this.floatingCursorDisabled = false,
Key? key}); Key? key});
@ -312,6 +312,21 @@ class QuillEditor extends StatefulWidget {
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final CustomStyleBuilder? customStyleBuilder; 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; final bool floatingCursorDisabled;
@override @override
@ -415,6 +430,7 @@ class _QuillEditorState extends State<QuillEditor>
enableInteractiveSelection: widget.enableInteractiveSelection, enableInteractiveSelection: widget.enableInteractiveSelection,
scrollPhysics: widget.scrollPhysics, scrollPhysics: widget.scrollPhysics,
embedBuilder: widget.embedBuilder, embedBuilder: widget.embedBuilder,
linkActionPickerDelegate: widget.linkActionPickerDelegate,
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
floatingCursorDisabled: widget.floatingCursorDisabled, floatingCursorDisabled: widget.floatingCursorDisabled,
); );
@ -520,20 +536,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
return false; return false;
} }
final segment = segmentResult.node as leaf.Leaf; 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) { if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
final blockEmbed = segment.value as BlockEmbed; final blockEmbed = segment.value as BlockEmbed;
if (blockEmbed.type == 'image') { if (blockEmbed.type == 'image') {
@ -557,10 +559,6 @@ class _QuillEditorSelectionGestureDetectorBuilder
return false; return false;
} }
Future<void> _launchUrl(String url) async {
await launch(url);
}
@override @override
void onTapDown(TapDownDetails details) { void onTapDown(TapDownDetails details) {
if (_state.widget.onTapDown != null) { 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:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../../models/documents/nodes/node.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/documents/document.dart'; import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/block.dart';
@ -21,6 +22,8 @@ import 'cursor.dart';
import 'default_styles.dart'; import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'editor.dart'; import 'editor.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart'; import 'proxy.dart';
import 'quill_single_child_scroll_view.dart'; import 'quill_single_child_scroll_view.dart';
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart'; import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
@ -62,6 +65,7 @@ class RawEditor extends StatefulWidget {
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.scrollPhysics, this.scrollPhysics,
this.embedBuilder = defaultEmbedBuilder, this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.floatingCursorDisabled = false}) this.floatingCursorDisabled = false})
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
@ -76,9 +80,25 @@ class RawEditor extends StatefulWidget {
final bool scrollable; final bool scrollable;
final double scrollBottomInset; final double scrollBottomInset;
final EdgeInsetsGeometry padding; 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 bool readOnly;
final String? placeholder; 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; 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 ToolbarOptions toolbarOptions;
final bool showSelectionHandles; final bool showSelectionHandles;
final bool showCursor; final bool showCursor;
@ -95,6 +115,7 @@ class RawEditor extends StatefulWidget {
final bool enableInteractiveSelection; final bool enableInteractiveSelection;
final ScrollPhysics? scrollPhysics; final ScrollPhysics? scrollPhysics;
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final LinkActionPickerDelegate linkActionPickerDelegate;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final bool floatingCursorDisabled; final bool floatingCursorDisabled;
@ -218,9 +239,11 @@ class RawEditorState extends EditorState
data: _styles!, data: _styles!,
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.text, cursor: SystemMouseCursors.text,
child: Container( child: QuillKeyboardListener(
constraints: constraints, child: Container(
child: child, constraints: constraints,
child: child,
),
), ),
), ),
); );
@ -270,6 +293,7 @@ class RawEditorState extends EditorState
final attrs = node.style.attributes; final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock( final editableTextBlock = EditableTextBlock(
block: node, block: node,
controller: widget.controller,
textDirection: _textDirection, textDirection: _textDirection,
scrollBottomInset: widget.scrollBottomInset, scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles), verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
@ -282,6 +306,8 @@ class RawEditorState extends EditorState
? const EdgeInsets.all(16) ? const EdgeInsets.all(16)
: null, : null,
embedBuilder: widget.embedBuilder, embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
cursorCont: _cursorCont, cursorCont: _cursorCont,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
onCheckboxTap: _handleCheckboxTap, onCheckboxTap: _handleCheckboxTap,
@ -304,6 +330,9 @@ class RawEditorState extends EditorState
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
styles: _styles!, styles: _styles!,
readOnly: widget.readOnly, readOnly: widget.readOnly,
controller: widget.controller,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
); );
final editableTextLine = EditableTextLine( final editableTextLine = EditableTextLine(
node, 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; bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() { void _showCaretOnScreen() {

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

@ -2,31 +2,37 @@ import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:tuple/tuple.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/container.dart' as container;
import '../models/documents/nodes/leaf.dart' as leaf; import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/leaf.dart';
import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart'; import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../utils/color.dart'; import '../utils/color.dart';
import 'box.dart'; import 'box.dart';
import 'cursor.dart'; import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart'; import 'proxy.dart';
import 'text_selection.dart'; import 'text_selection.dart';
class TextLine extends StatelessWidget { class TextLine extends StatefulWidget {
const TextLine({ const TextLine({
required this.line, required this.line,
required this.embedBuilder, required this.embedBuilder,
required this.styles, required this.styles,
required this.readOnly, required this.readOnly,
required this.controller,
required this.onLaunchUrl,
required this.linkActionPicker,
this.textDirection, this.textDirection,
this.customStyleBuilder, this.customStyleBuilder,
Key? key, Key? key,
@ -37,23 +43,109 @@ class TextLine extends StatelessWidget {
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final DefaultStyles styles; final DefaultStyles styles;
final bool readOnly; final bool readOnly;
final QuillController controller;
final CustomStyleBuilder? customStyleBuilder; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(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 // For video, it is always single child
final embed = line.children.single as Embed; final embed = widget.line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed, readOnly)); return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly));
} }
final textSpan = _getTextSpanForWholeLine(context); final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign(); final textAlign = _getTextAlign();
final child = RichText( final child = RichText(
key: _richTextKey,
text: textSpan, text: textSpan,
textAlign: textAlign, textAlign: textAlign,
textDirection: textDirection, textDirection: widget.textDirection,
strutStyle: strutStyle, strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context), textScaleFactor: MediaQuery.textScaleFactorOf(context),
); );
@ -61,7 +153,7 @@ class TextLine extends StatelessWidget {
child, child,
textSpan.style!, textSpan.style!,
textAlign, textAlign,
textDirection!, widget.textDirection!,
1, 1,
Localizations.localeOf(context), Localizations.localeOf(context),
strutStyle, strutStyle,
@ -70,23 +162,25 @@ class TextLine extends StatelessWidget {
} }
InlineSpan _getTextSpanForWholeLine(BuildContext context) { InlineSpan _getTextSpanForWholeLine(BuildContext context) {
final lineStyle = _getLineStyle(styles); final lineStyle = _getLineStyle(widget.styles);
if (!line.hasEmbed) { if (!widget.line.hasEmbed) {
return _buildTextSpan(styles, line.children, lineStyle); return _buildTextSpan(widget.styles, widget.line.children, lineStyle);
} }
// The line could contain more than one Embed & more than one Text // The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[]; final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>(); var textNodes = LinkedList<Node>();
for (final child in line.children) { for (final child in widget.line.children) {
if (child is Embed) { if (child is Embed) {
if (textNodes.isNotEmpty) { if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>(); textNodes = LinkedList<Node>();
} }
// Here it should be image // Here it should be image
final embed = WidgetSpan( final embed = WidgetSpan(
child: EmbedProxy(embedBuilder(context, child, readOnly))); child: EmbedProxy(
widget.embedBuilder(context, child, widget.readOnly)));
textSpanChildren.add(embed); textSpanChildren.add(embed);
continue; continue;
} }
@ -96,14 +190,14 @@ class TextLine extends StatelessWidget {
} }
if (textNodes.isNotEmpty) { if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
} }
return TextSpan(style: lineStyle, children: textSpanChildren); return TextSpan(style: lineStyle, children: textSpanChildren);
} }
TextAlign _getTextAlign() { TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key]; final alignment = widget.line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) { if (alignment == Attribute.leftAlignment) {
return TextAlign.start; return TextAlign.start;
} else if (alignment == Attribute.centerAlignment) { } else if (alignment == Attribute.centerAlignment) {
@ -119,7 +213,8 @@ class TextLine extends StatelessWidget {
TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes, TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes,
TextStyle lineStyle) { TextStyle lineStyle) {
final children = nodes final children = nodes
.map((node) => _getTextSpanFromNode(defaultStyles, node, line.style)) .map((node) =>
_getTextSpanFromNode(defaultStyles, node, widget.line.style))
.toList(growable: false); .toList(growable: false);
return TextSpan(children: children, style: lineStyle); return TextSpan(children: children, style: lineStyle);
@ -128,11 +223,11 @@ class TextLine extends StatelessWidget {
TextStyle _getLineStyle(DefaultStyles defaultStyles) { TextStyle _getLineStyle(DefaultStyles defaultStyles) {
var textStyle = const TextStyle(); var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) { if (widget.line.style.containsKey(Attribute.placeholder.key)) {
return defaultStyles.placeHolder!.style; 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>{ final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style, Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style, Attribute.h2: defaultStyles.h2!.style,
@ -143,7 +238,7 @@ class TextLine extends StatelessWidget {
// Only retrieve exclusive block format for the line style purpose // Only retrieve exclusive block format for the line style purpose
Attribute? block; Attribute? block;
line.style.getBlocksExceptHeader().forEach((key, value) { widget.line.style.getBlocksExceptHeader().forEach((key, value) {
if (Attribute.exclusiveBlockKeys.contains(key)) { if (Attribute.exclusiveBlockKeys.contains(key)) {
block = value; block = value;
} }
@ -159,21 +254,21 @@ class TextLine extends StatelessWidget {
} }
textStyle = textStyle.merge(toMerge); textStyle = textStyle.merge(toMerge);
textStyle = _applyCustomAttributes(textStyle, line.style.attributes); textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes);
return textStyle; return textStyle;
} }
TextStyle _applyCustomAttributes( TextStyle _applyCustomAttributes(
TextStyle textStyle, Map<String, Attribute> attributes) { TextStyle textStyle, Map<String, Attribute> attributes) {
if (customStyleBuilder == null) { if (widget.customStyleBuilder == null) {
return textStyle; return textStyle;
} }
attributes.keys.forEach((key) { attributes.keys.forEach((key) {
final attr = attributes[key]; final attr = attributes[key];
if (attr != null) { if (attr != null) {
/// Custom Attribute /// Custom Attribute
final customAttr = customStyleBuilder!.call(attr); final customAttr = widget.customStyleBuilder!.call(attr);
textStyle = textStyle.merge(customAttr); textStyle = textStyle.merge(customAttr);
} }
}); });
@ -184,6 +279,7 @@ class TextLine extends StatelessWidget {
DefaultStyles defaultStyles, Node node, Style lineStyle) { DefaultStyles defaultStyles, Node node, Style lineStyle) {
final textNode = node as leaf.Text; final textNode = node as leaf.Text;
final nodeStyle = textNode.style; final nodeStyle = textNode.style;
final isLink = nodeStyle.containsKey(Attribute.link.key);
var res = const TextStyle(); // This is inline text style var res = const TextStyle(); // This is inline text style
final color = textNode.style.attributes[Attribute.color.key]; final color = textNode.style.attributes[Attribute.color.key];
var hasLink = false; var hasLink = false;
@ -268,14 +364,108 @@ class TextLine extends StatelessWidget {
} }
res = _applyCustomAttributes(res, textNode.style.attributes); res = _applyCustomAttributes(res, textNode.style.attributes);
if (hasLink && readOnly) { if (hasLink && widget.readOnly) {
return TextSpan( return TextSpan(
text: textNode.value, text: textNode.value,
style: res, style: res,
mouseCursor: SystemMouseCursors.click, 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) { TextStyle _merge(TextStyle a, TextStyle b) {

Loading…
Cancel
Save