feat: выделение текста в сообщении
This commit is contained in:
@@ -469,6 +469,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<bool> _animojiHold = ValueNotifier(true);
|
final ValueNotifier<bool> _animojiHold = ValueNotifier(true);
|
||||||
|
|
||||||
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
|
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
|
||||||
|
final ValueNotifier<({String id, Offset pos})?> _textSelection =
|
||||||
|
ValueNotifier(null);
|
||||||
late final AnimationController _selectionAnim;
|
late final AnimationController _selectionAnim;
|
||||||
|
|
||||||
bool get _selectionMode => _selectedIds.value.isNotEmpty;
|
bool get _selectionMode => _selectedIds.value.isNotEmpty;
|
||||||
@@ -503,6 +505,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_scrollController.addListener(_maybeLoadMoreHistory);
|
_scrollController.addListener(_maybeLoadMoreHistory);
|
||||||
_scrollController.addListener(_recordScrollPixels);
|
_scrollController.addListener(_recordScrollPixels);
|
||||||
_scrollController.addListener(_scheduleReadMarker);
|
_scrollController.addListener(_scheduleReadMarker);
|
||||||
|
_scrollController.addListener(_exitTextSelectionOnScroll);
|
||||||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||||||
AppChatChrome.current.addListener(_onVisualStyleChanged);
|
AppChatChrome.current.addListener(_onVisualStyleChanged);
|
||||||
_shimmerController = AnimationController(
|
_shimmerController = AnimationController(
|
||||||
@@ -1322,6 +1325,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_scrollController.removeListener(_maybeLoadMoreHistory);
|
_scrollController.removeListener(_maybeLoadMoreHistory);
|
||||||
_scrollController.removeListener(_recordScrollPixels);
|
_scrollController.removeListener(_recordScrollPixels);
|
||||||
_scrollController.removeListener(_scheduleReadMarker);
|
_scrollController.removeListener(_scheduleReadMarker);
|
||||||
|
_scrollController.removeListener(_exitTextSelectionOnScroll);
|
||||||
_readMarkTimer?.cancel();
|
_readMarkTimer?.cancel();
|
||||||
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
|
||||||
AppChatChrome.current.removeListener(_onVisualStyleChanged);
|
AppChatChrome.current.removeListener(_onVisualStyleChanged);
|
||||||
@@ -1370,6 +1374,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
_search.dispose();
|
_search.dispose();
|
||||||
_selectedIds.dispose();
|
_selectedIds.dispose();
|
||||||
|
_textSelection.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_messageFocusNode.dispose();
|
_messageFocusNode.dispose();
|
||||||
_stickers.dispose();
|
_stickers.dispose();
|
||||||
@@ -1512,15 +1517,38 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (!next.remove(message.id)) next.add(message.id);
|
if (!next.remove(message.id)) next.add(message.id);
|
||||||
Haptics.selection();
|
Haptics.selection();
|
||||||
_selectedIds.value = next;
|
_selectedIds.value = next;
|
||||||
|
if (!next.contains(message.id)) _exitTextSelection(message.id);
|
||||||
_syncSelectionAnim();
|
_syncSelectionAnim();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
|
_exitTextSelection();
|
||||||
if (_selectedIds.value.isEmpty) return;
|
if (_selectedIds.value.isEmpty) return;
|
||||||
_selectedIds.value = const {};
|
_selectedIds.value = const {};
|
||||||
_syncSelectionAnim();
|
_syncSelectionAnim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startTextSelection(CachedMessage message, Offset globalPosition) {
|
||||||
|
if (message.isControl || (message.text?.isEmpty ?? true)) return;
|
||||||
|
_textSelection.value = (id: message.id, pos: globalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitTextSelection([String? onlyId]) {
|
||||||
|
final current = _textSelection.value;
|
||||||
|
if (current == null) return;
|
||||||
|
if (onlyId != null && current.id != onlyId) return;
|
||||||
|
_textSelection.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitTextSelectionOnScroll() {
|
||||||
|
if (_textSelection.value == null) return;
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
if (_scrollController.position.userScrollDirection !=
|
||||||
|
ScrollDirection.idle) {
|
||||||
|
_exitTextSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _syncSelectionAnim() {
|
void _syncSelectionAnim() {
|
||||||
if (_selectedIds.value.isEmpty) {
|
if (_selectedIds.value.isEmpty) {
|
||||||
_selectionAnim.reverse();
|
_selectionAnim.reverse();
|
||||||
@@ -3964,13 +3992,21 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
? math.max(mq.viewInsets.bottom, _keyboardReserve)
|
? math.max(mq.viewInsets.bottom, _keyboardReserve)
|
||||||
: mq.viewInsets.bottom;
|
: mq.viewInsets.bottom;
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Listenable.merge([_selectedIds, _search.searchMode]),
|
listenable: Listenable.merge([
|
||||||
|
_selectedIds,
|
||||||
|
_search.searchMode,
|
||||||
|
_textSelection,
|
||||||
|
]),
|
||||||
builder: (context, child) => PopScope(
|
builder: (context, child) => PopScope(
|
||||||
canPop: _selectedIds.value.isEmpty && !_search.searchMode.value,
|
canPop: _selectedIds.value.isEmpty &&
|
||||||
|
!_search.searchMode.value &&
|
||||||
|
_textSelection.value == null,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
if (_search.searchMode.value) {
|
if (_search.searchMode.value) {
|
||||||
_closeSearch();
|
_closeSearch();
|
||||||
|
} else if (_textSelection.value != null) {
|
||||||
|
_exitTextSelection();
|
||||||
} else {
|
} else {
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
}
|
}
|
||||||
@@ -4338,6 +4374,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
: (emoji) => _reactToMessage(message, emoji),
|
: (emoji) => _reactToMessage(message, emoji),
|
||||||
peerName: widget.name,
|
peerName: widget.name,
|
||||||
peerAvatarUrl: widget.imageUrl,
|
peerAvatarUrl: widget.imageUrl,
|
||||||
|
textSelection: _textSelection,
|
||||||
|
onExitTextSelection: _exitTextSelection,
|
||||||
);
|
);
|
||||||
|
|
||||||
final canReport = !isMe && !message.isControl;
|
final canReport = !isMe && !message.isControl;
|
||||||
@@ -4353,6 +4391,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
isSelectionActive: () => _selectionMode,
|
isSelectionActive: () => _selectionMode,
|
||||||
onToggleSelection: () => _toggleSelection(message),
|
onToggleSelection: () => _toggleSelection(message),
|
||||||
onEnterSelection: () => _enterSelection(message),
|
onEnterSelection: () => _enterSelection(message),
|
||||||
|
onStartTextSelection: (pos) =>
|
||||||
|
_startTextSelection(message, pos),
|
||||||
onDelete: () => _confirmDeleteMessage(message, isMe),
|
onDelete: () => _confirmDeleteMessage(message, isMe),
|
||||||
onEdit: _canEditMessage(message)
|
onEdit: _canEditMessage(message)
|
||||||
? () => _startEditMessage(message)
|
? () => _startEditMessage(message)
|
||||||
@@ -5664,6 +5704,7 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
final bool Function() isSelectionActive;
|
final bool Function() isSelectionActive;
|
||||||
final VoidCallback onToggleSelection;
|
final VoidCallback onToggleSelection;
|
||||||
final VoidCallback onEnterSelection;
|
final VoidCallback onEnterSelection;
|
||||||
|
final void Function(Offset globalPosition) onStartTextSelection;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
@@ -5685,6 +5726,7 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
required this.isSelectionActive,
|
required this.isSelectionActive,
|
||||||
required this.onToggleSelection,
|
required this.onToggleSelection,
|
||||||
required this.onEnterSelection,
|
required this.onEnterSelection,
|
||||||
|
required this.onStartTextSelection,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onReply,
|
this.onReply,
|
||||||
@@ -5847,11 +5889,17 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPress() {
|
void _handleLongPressStart(Offset globalPosition) {
|
||||||
if (widget.isSelectionActive()) {
|
if (!widget.isSelectionActive()) {
|
||||||
widget.onToggleSelection();
|
|
||||||
} else {
|
|
||||||
widget.onEnterSelection();
|
widget.onEnterSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final selected = widget.selectedIds.value.contains(widget.message.id);
|
||||||
|
final hasText = widget.message.text?.isNotEmpty ?? false;
|
||||||
|
if (selected && hasText && !widget.message.isControl) {
|
||||||
|
widget.onStartTextSelection(globalPosition);
|
||||||
|
} else {
|
||||||
|
widget.onToggleSelection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5896,7 +5944,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (d) => _lastTapDown = d.globalPosition,
|
onTapDown: (d) => _lastTapDown = d.globalPosition,
|
||||||
onTap: _handleTap,
|
onTap: _handleTap,
|
||||||
onLongPress: _handleLongPress,
|
onLongPressStart: (d) => _handleLongPressStart(d.globalPosition),
|
||||||
onSecondaryTapDown: active ? null : _onSecondaryTapDown,
|
onSecondaryTapDown: active ? null : _onSecondaryTapDown,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../../core/utils/webview_support.dart';
|
|||||||
import '../../core/config/app_link_preview.dart';
|
import '../../core/config/app_link_preview.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'formatted_message_text.dart';
|
import 'formatted_message_text.dart';
|
||||||
|
import 'selectable_message_text.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
import '../../models/reaction_info.dart';
|
import '../../models/reaction_info.dart';
|
||||||
import 'attachment/bubbles/voice_bubble.dart';
|
import 'attachment/bubbles/voice_bubble.dart';
|
||||||
@@ -134,6 +135,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final void Function(String emoji)? onReactionTap;
|
final void Function(String emoji)? onReactionTap;
|
||||||
final String? peerName;
|
final String? peerName;
|
||||||
final String? peerAvatarUrl;
|
final String? peerAvatarUrl;
|
||||||
|
final ValueListenable<({String id, Offset pos})?>? textSelection;
|
||||||
|
final VoidCallback? onExitTextSelection;
|
||||||
|
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -153,6 +156,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
this.onReactionTap,
|
this.onReactionTap,
|
||||||
this.peerName,
|
this.peerName,
|
||||||
this.peerAvatarUrl,
|
this.peerAvatarUrl,
|
||||||
|
this.textSelection,
|
||||||
|
this.onExitTextSelection,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool _computeHasPhotoWithCaption() {
|
bool _computeHasPhotoWithCaption() {
|
||||||
@@ -1082,6 +1087,25 @@ class MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _wrapSelectable(Widget textWidget) {
|
||||||
|
final listenable = textSelection;
|
||||||
|
if (listenable == null || (message.text?.isEmpty ?? true)) {
|
||||||
|
return textWidget;
|
||||||
|
}
|
||||||
|
return ValueListenableBuilder<({String id, Offset pos})?>(
|
||||||
|
valueListenable: listenable,
|
||||||
|
builder: (context, req, child) {
|
||||||
|
if (req == null || req.id != message.id) return child!;
|
||||||
|
return SelectableMessageText(
|
||||||
|
initialGlobalPosition: req.pos,
|
||||||
|
onExit: onExitTextSelection ?? () {},
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: textWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTextContent(BubbleContext ctx) {
|
Widget _buildTextContent(BubbleContext ctx) {
|
||||||
final attachments = message.attachments;
|
final attachments = message.attachments;
|
||||||
final isForwardedContact =
|
final isForwardedContact =
|
||||||
@@ -1102,7 +1126,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
||||||
final ranges = message.formatRanges;
|
final ranges = message.formatRanges;
|
||||||
final textWidget = isForwarded
|
final baseTextWidget = isForwarded
|
||||||
? _buildForwardedInlineText(ctx, forwarded)
|
? _buildForwardedInlineText(ctx, forwarded)
|
||||||
: (FormattedMessageText.isFormatted(message.text, ranges)
|
: (FormattedMessageText.isFormatted(message.text, ranges)
|
||||||
? FormattedMessageText(
|
? FormattedMessageText(
|
||||||
@@ -1111,6 +1135,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
style: textStyle,
|
style: textStyle,
|
||||||
)
|
)
|
||||||
: Text(message.text ?? '', style: textStyle));
|
: Text(message.text ?? '', style: textStyle));
|
||||||
|
final textWidget = _wrapSelectable(baseTextWidget);
|
||||||
|
|
||||||
final metaWidget = Text(
|
final metaWidget = Text(
|
||||||
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
|
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/haptics.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
|
|
||||||
|
RenderParagraph? _findParagraph(RenderObject? ro) {
|
||||||
|
if (ro == null) return null;
|
||||||
|
if (ro is RenderParagraph) return ro;
|
||||||
|
RenderParagraph? found;
|
||||||
|
ro.visitChildren((child) {
|
||||||
|
found ??= _findParagraph(child);
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSpace(int c) =>
|
||||||
|
c == 0x20 ||
|
||||||
|
c == 0x09 ||
|
||||||
|
c == 0x0A ||
|
||||||
|
c == 0x0D ||
|
||||||
|
c == 0x0C ||
|
||||||
|
c == 0xA0;
|
||||||
|
|
||||||
|
class SelectableMessageText extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final Offset initialGlobalPosition;
|
||||||
|
final VoidCallback onExit;
|
||||||
|
|
||||||
|
const SelectableMessageText({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.initialGlobalPosition,
|
||||||
|
required this.onExit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SelectableMessageText> createState() => _SelectableMessageTextState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
static const double _ballRadius = 8.0;
|
||||||
|
static const double _hitSize = 44.0;
|
||||||
|
|
||||||
|
final GlobalKey _textKey = GlobalKey();
|
||||||
|
final ValueNotifier<bool> _toolbarVisible = ValueNotifier(false);
|
||||||
|
|
||||||
|
late final AnimationController _entrance;
|
||||||
|
OverlayEntry? _overlay;
|
||||||
|
Timer? _settle;
|
||||||
|
TextSelection _selection = const TextSelection.collapsed(offset: 0);
|
||||||
|
String? _cachedText;
|
||||||
|
bool _dragging = false;
|
||||||
|
bool _exiting = false;
|
||||||
|
|
||||||
|
RenderParagraph? _cachedParagraph;
|
||||||
|
|
||||||
|
RenderParagraph? get _paragraph {
|
||||||
|
final cached = _cachedParagraph;
|
||||||
|
if (cached != null && cached.attached) return cached;
|
||||||
|
return _cachedParagraph = _findParagraph(
|
||||||
|
_textKey.currentContext?.findRenderObject(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_entrance = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
)..addListener(() => _overlay?.markNeedsBuild());
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _init(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_settle?.cancel();
|
||||||
|
_entrance.dispose();
|
||||||
|
_overlay?.remove();
|
||||||
|
_overlay = null;
|
||||||
|
_toolbarVisible.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _init(int retries) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp == null || !rp.hasSize) {
|
||||||
|
if (retries > 0) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1));
|
||||||
|
} else {
|
||||||
|
_requestExit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectWordAt(widget.initialGlobalPosition, rp);
|
||||||
|
Haptics.selection();
|
||||||
|
_ensureOverlay();
|
||||||
|
_toolbarVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureOverlay() {
|
||||||
|
if (_overlay != null || !mounted) return;
|
||||||
|
_overlay = OverlayEntry(builder: _buildOverlay);
|
||||||
|
Overlay.of(context, rootOverlay: true).insert(_overlay!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requestExit() {
|
||||||
|
if (_exiting) return;
|
||||||
|
_exiting = true;
|
||||||
|
_settle?.cancel();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.onExit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSelection _normalize(int a, int b) => TextSelection(
|
||||||
|
baseOffset: math.min(a, b),
|
||||||
|
extentOffset: math.max(a, b),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _applySelection(TextSelection sel, {bool animate = false}) {
|
||||||
|
_selection = sel;
|
||||||
|
if (animate) _entrance.forward(from: 0);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
_overlay?.markNeedsBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRange _wordRange(RenderParagraph rp, Offset globalPos) {
|
||||||
|
final text = _text(rp);
|
||||||
|
final len = text.length;
|
||||||
|
if (len == 0) return const TextRange.collapsed(0);
|
||||||
|
final local = rp.globalToLocal(globalPos);
|
||||||
|
var off = rp.getPositionForOffset(local).offset.clamp(0, len);
|
||||||
|
bool ws(int i) => i < 0 || i >= len || _isSpace(text.codeUnitAt(i));
|
||||||
|
if (ws(off) && off > 0 && !ws(off - 1)) off -= 1;
|
||||||
|
if (ws(off)) return TextRange.collapsed(off);
|
||||||
|
var s = off;
|
||||||
|
var e = off;
|
||||||
|
while (s > 0 && !_isSpace(text.codeUnitAt(s - 1))) {
|
||||||
|
s--;
|
||||||
|
}
|
||||||
|
while (e < len && !_isSpace(text.codeUnitAt(e))) {
|
||||||
|
e++;
|
||||||
|
}
|
||||||
|
return TextRange(start: s, end: e);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectWordAt(Offset globalPos, RenderParagraph rp) {
|
||||||
|
final range = _wordRange(rp, globalPos);
|
||||||
|
if (range.isCollapsed) {
|
||||||
|
_applySelection(_normalize(0, _text(rp).length), animate: true);
|
||||||
|
} else {
|
||||||
|
_applySelection(_normalize(range.start, range.end), animate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onBackgroundTap(Offset globalPos) {
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp == null || !rp.hasSize) {
|
||||||
|
_requestExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final local = rp.globalToLocal(globalPos);
|
||||||
|
if (rp.size.contains(local)) {
|
||||||
|
_dragging = false;
|
||||||
|
_settle?.cancel();
|
||||||
|
_selectWordAt(globalPos, rp);
|
||||||
|
_toolbarVisible.value = true;
|
||||||
|
} else {
|
||||||
|
_requestExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHandleDragStart() {
|
||||||
|
_dragging = true;
|
||||||
|
_settle?.cancel();
|
||||||
|
_entrance.value = 1.0;
|
||||||
|
_toolbarVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHandleDrag(Offset globalPos, bool isStart) {
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp == null || !rp.hasSize) return;
|
||||||
|
final len = _text(rp).length;
|
||||||
|
final off = rp.getPositionForOffset(rp.globalToLocal(globalPos)).offset;
|
||||||
|
if (isStart) {
|
||||||
|
final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt();
|
||||||
|
_applySelection(
|
||||||
|
TextSelection(baseOffset: ns, extentOffset: _selection.end),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final ne = off.clamp(math.min(_selection.start + 1, len), len).toInt();
|
||||||
|
_applySelection(
|
||||||
|
TextSelection(baseOffset: _selection.start, extentOffset: ne),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHandleDragEnd() {
|
||||||
|
_dragging = false;
|
||||||
|
_settle?.cancel();
|
||||||
|
_settle = Timer(const Duration(milliseconds: 140), () {
|
||||||
|
if (!mounted || _dragging) return;
|
||||||
|
_overlay?.markNeedsBuild();
|
||||||
|
_toolbarVisible.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copy() {
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp != null && _selection.isValid && !_selection.isCollapsed) {
|
||||||
|
final text = _text(rp);
|
||||||
|
final sub = text.substring(
|
||||||
|
_selection.start.clamp(0, text.length),
|
||||||
|
_selection.end.clamp(0, text.length),
|
||||||
|
);
|
||||||
|
if (sub.isNotEmpty) {
|
||||||
|
Clipboard.setData(ClipboardData(text: sub));
|
||||||
|
Haptics.tap();
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.msgActionsCopied,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_requestExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAll() {
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp == null) return;
|
||||||
|
Haptics.tap();
|
||||||
|
_applySelection(_normalize(0, _text(rp).length), animate: true);
|
||||||
|
_toolbarVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOverlay(BuildContext ctx) {
|
||||||
|
final rp = _paragraph;
|
||||||
|
if (rp == null || !rp.hasSize || !rp.attached) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final cs = Theme.of(ctx).colorScheme;
|
||||||
|
|
||||||
|
final List<ui.TextBox> boxes =
|
||||||
|
(_selection.isValid && !_selection.isCollapsed)
|
||||||
|
? rp.getBoxesForSelection(_selection)
|
||||||
|
: const [];
|
||||||
|
|
||||||
|
final children = <Widget>[
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapUp: (d) => _onBackgroundTap(d.globalPosition),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (boxes.isNotEmpty) {
|
||||||
|
final first = boxes.first.toRect();
|
||||||
|
final last = boxes.last.toRect();
|
||||||
|
final startBottom = rp.localToGlobal(Offset(first.left, first.bottom));
|
||||||
|
final endBottom = rp.localToGlobal(Offset(last.right, last.bottom));
|
||||||
|
|
||||||
|
children.add(_handle(cs, startBottom, isStart: true));
|
||||||
|
children.add(_handle(cs, endBottom, isStart: false));
|
||||||
|
children.add(_toolbar(ctx, rp, first, last));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(children: children);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _handle(ColorScheme cs, Offset lineBottomGlobal, {required bool isStart}) {
|
||||||
|
final center = Offset(
|
||||||
|
lineBottomGlobal.dx,
|
||||||
|
lineBottomGlobal.dy + _ballRadius,
|
||||||
|
);
|
||||||
|
return Positioned(
|
||||||
|
left: center.dx - _hitSize / 2,
|
||||||
|
top: center.dy - _hitSize / 2,
|
||||||
|
width: _hitSize,
|
||||||
|
height: _hitSize,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPanStart: (_) => _onHandleDragStart(),
|
||||||
|
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
|
||||||
|
onPanEnd: (_) => _onHandleDragEnd(),
|
||||||
|
onPanCancel: _onHandleDragEnd,
|
||||||
|
child: Center(
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: Curves.easeOutBack.transform(_entrance.value.clamp(0.0, 1.0)),
|
||||||
|
child: Container(
|
||||||
|
width: _ballRadius * 2,
|
||||||
|
height: _ballRadius * 2,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) {
|
||||||
|
final media = MediaQuery.of(ctx);
|
||||||
|
final size = media.size;
|
||||||
|
final safeTop = media.padding.top + 8;
|
||||||
|
final safeBottom = size.height - media.padding.bottom - 8;
|
||||||
|
const height = 48.0;
|
||||||
|
const gap = 10.0;
|
||||||
|
|
||||||
|
final topGlobal = rp.localToGlobal(first.topLeft).dy;
|
||||||
|
final bottomGlobal = rp.localToGlobal(Offset(last.right, last.bottom)).dy;
|
||||||
|
|
||||||
|
double top = topGlobal - gap - height;
|
||||||
|
if (top < safeTop) top = bottomGlobal + gap;
|
||||||
|
top = top.clamp(safeTop, math.max(safeTop, safeBottom - height));
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
top: top,
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _toolbarVisible,
|
||||||
|
builder: (ctx, visible, _) => IgnorePointer(
|
||||||
|
ignoring: !visible,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: visible ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Center(child: _pill(ctx)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _pill(BuildContext ctx) {
|
||||||
|
final cs = Theme.of(ctx).colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(ctx)!;
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_toolbarButton(cs, l10n.msgActionsCopy, _copy),
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
color: cs.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
_toolbarButton(cs, l10n.msgActionsSelectAll, _selectAll),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toolbarButton(ColorScheme cs, String label, VoidCallback onTap) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 13),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _HighlightPainter(
|
||||||
|
paragraph: _paragraph,
|
||||||
|
selection: _selection,
|
||||||
|
animation: _entrance,
|
||||||
|
fill: cs.primary.withValues(alpha: 0.28),
|
||||||
|
stem: cs.primary,
|
||||||
|
),
|
||||||
|
child: KeyedSubtree(key: _textKey, child: widget.child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HighlightPainter extends CustomPainter {
|
||||||
|
final RenderParagraph? paragraph;
|
||||||
|
final TextSelection selection;
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Color fill;
|
||||||
|
final Color stem;
|
||||||
|
|
||||||
|
_HighlightPainter({
|
||||||
|
required this.paragraph,
|
||||||
|
required this.selection,
|
||||||
|
required this.animation,
|
||||||
|
required this.fill,
|
||||||
|
required this.stem,
|
||||||
|
}) : super(repaint: animation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (!selection.isValid || selection.isCollapsed) return;
|
||||||
|
final rp = paragraph;
|
||||||
|
if (rp == null || !rp.hasSize || !rp.attached) return;
|
||||||
|
final boxes = rp.getBoxesForSelection(selection);
|
||||||
|
if (boxes.isEmpty) return;
|
||||||
|
|
||||||
|
final t = animation.value.clamp(0.0, 1.0);
|
||||||
|
final eased = Curves.easeOut.transform(t);
|
||||||
|
final grow = 0.72 + 0.28 * eased;
|
||||||
|
|
||||||
|
final fillPaint = Paint()..color = fill.withValues(alpha: fill.a * eased);
|
||||||
|
for (final box in boxes) {
|
||||||
|
final rect = box.toRect().inflate(0.5);
|
||||||
|
final cy = rect.center.dy;
|
||||||
|
final h = rect.height * grow;
|
||||||
|
final animRect = Rect.fromLTRB(
|
||||||
|
rect.left,
|
||||||
|
cy - h / 2,
|
||||||
|
rect.right,
|
||||||
|
cy + h / 2,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(animRect, const Radius.circular(3)),
|
||||||
|
fillPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stemPaint = Paint()
|
||||||
|
..color = stem.withValues(alpha: stem.a * eased)
|
||||||
|
..strokeWidth = 2.5
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
final first = boxes.first.toRect();
|
||||||
|
final last = boxes.last.toRect();
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(first.left, first.top),
|
||||||
|
Offset(first.left, first.bottom),
|
||||||
|
stemPaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(last.right, last.top),
|
||||||
|
Offset(last.right, last.bottom),
|
||||||
|
stemPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_HighlightPainter old) =>
|
||||||
|
old.selection != selection ||
|
||||||
|
old.paragraph != paragraph ||
|
||||||
|
old.fill != fill ||
|
||||||
|
old.stem != stem;
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@
|
|||||||
"registrationSubtitle": "Add your name and pick an avatar",
|
"registrationSubtitle": "Add your name and pick an avatar",
|
||||||
"registrationChooseAvatar": "Choose an avatar",
|
"registrationChooseAvatar": "Choose an avatar",
|
||||||
"msgActionsCopy": "Copy",
|
"msgActionsCopy": "Copy",
|
||||||
|
"msgActionsSelectAll": "Select all",
|
||||||
"emojiSearchHint": "Search emoji",
|
"emojiSearchHint": "Search emoji",
|
||||||
"msgActionsEdit": "Edit",
|
"msgActionsEdit": "Edit",
|
||||||
"msgActionsReply": "Reply",
|
"msgActionsReply": "Reply",
|
||||||
|
|||||||
@@ -1094,6 +1094,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Copy'**
|
/// **'Copy'**
|
||||||
String get msgActionsCopy;
|
String get msgActionsCopy;
|
||||||
|
|
||||||
|
/// No description provided for @msgActionsSelectAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select all'**
|
||||||
|
String get msgActionsSelectAll;
|
||||||
|
|
||||||
/// No description provided for @emojiSearchHint.
|
/// No description provided for @emojiSearchHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsCopy => 'Copy';
|
String get msgActionsCopy => 'Copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsSelectAll => 'Select all';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get emojiSearchHint => 'Search emoji';
|
String get emojiSearchHint => 'Search emoji';
|
||||||
|
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsCopy => 'Копировать';
|
String get msgActionsCopy => 'Копировать';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get msgActionsSelectAll => 'Выбрать всё';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get emojiSearchHint => 'Поиск эмодзи';
|
String get emojiSearchHint => 'Поиск эмодзи';
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"registrationSubtitle": "Укажите имя и выберите аватар",
|
"registrationSubtitle": "Укажите имя и выберите аватар",
|
||||||
"registrationChooseAvatar": "Выберите аватар",
|
"registrationChooseAvatar": "Выберите аватар",
|
||||||
"msgActionsCopy": "Копировать",
|
"msgActionsCopy": "Копировать",
|
||||||
|
"msgActionsSelectAll": "Выбрать всё",
|
||||||
"emojiSearchHint": "Поиск эмодзи",
|
"emojiSearchHint": "Поиск эмодзи",
|
||||||
"msgActionsEdit": "Изменить",
|
"msgActionsEdit": "Изменить",
|
||||||
"msgActionsReply": "Ответить",
|
"msgActionsReply": "Ответить",
|
||||||
|
|||||||
Reference in New Issue
Block a user