feat/fix/refactor: адекватный UX чата существует
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
@@ -10,34 +11,64 @@ 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;
|
||||
void _collectParagraphs(RenderObject ro, List<RenderParagraph> out) {
|
||||
if (ro is RenderParagraph) {
|
||||
out.add(ro);
|
||||
return;
|
||||
}
|
||||
ro.visitChildren((child) => _collectParagraphs(child, out));
|
||||
}
|
||||
|
||||
bool _isSpace(int c) =>
|
||||
c == 0x20 ||
|
||||
c == 0x09 ||
|
||||
c == 0x0A ||
|
||||
c == 0x0D ||
|
||||
c == 0x0C ||
|
||||
c == 0xA0;
|
||||
c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0xA0;
|
||||
|
||||
bool _isGlyphOnly(String text) {
|
||||
if (text.isEmpty) return true;
|
||||
for (final rune in text.runes) {
|
||||
final private =
|
||||
(rune >= 0xE000 && rune <= 0xF8FF) ||
|
||||
(rune >= 0xF0000 && rune <= 0xFFFFD) ||
|
||||
(rune >= 0x100000 && rune <= 0x10FFFD);
|
||||
if (!private) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class _ParaSlice {
|
||||
final RenderParagraph rp;
|
||||
final int start;
|
||||
final String text;
|
||||
|
||||
const _ParaSlice({required this.rp, required this.start, required this.text});
|
||||
|
||||
int get end => start + text.length;
|
||||
|
||||
bool get usable => rp.attached && rp.hasSize;
|
||||
|
||||
Offset get origin => rp.localToGlobal(Offset.zero);
|
||||
|
||||
Rect get globalRect => origin & rp.size;
|
||||
|
||||
TextSelection? clip(TextSelection selection) {
|
||||
final s = selection.start.clamp(start, end) - start;
|
||||
final e = selection.end.clamp(start, end) - start;
|
||||
if (e <= s) return null;
|
||||
return TextSelection(baseOffset: s, extentOffset: e);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectableMessageText extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Offset initialGlobalPosition;
|
||||
final VoidCallback onExit;
|
||||
final ValueListenable<Offset?>? dragPosition;
|
||||
|
||||
const SelectableMessageText({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.initialGlobalPosition,
|
||||
required this.onExit,
|
||||
this.dragPosition,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -53,30 +84,111 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
static const double _hitBelow = 42.0;
|
||||
|
||||
final GlobalKey _textKey = GlobalKey();
|
||||
final LayerLink _link = LayerLink();
|
||||
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;
|
||||
double? _dragStartLocalY;
|
||||
double? _dragStartGlobalY;
|
||||
double _dragAnchorY = 0;
|
||||
double _dragLineHeight = 0;
|
||||
|
||||
RenderParagraph? _cachedParagraph;
|
||||
List<_ParaSlice>? _cachedSlices;
|
||||
String _cachedJoined = '';
|
||||
ScrollPosition? _scrollPosition;
|
||||
TextSelection? _anchor;
|
||||
|
||||
RenderParagraph? get _paragraph {
|
||||
final cached = _cachedParagraph;
|
||||
if (cached != null && cached.attached) return cached;
|
||||
return _cachedParagraph = _findParagraph(
|
||||
_textKey.currentContext?.findRenderObject(),
|
||||
);
|
||||
List<_ParaSlice> get _slices {
|
||||
final cached = _cachedSlices;
|
||||
if (cached != null && cached.isNotEmpty && cached.every((s) => s.usable)) {
|
||||
return cached;
|
||||
}
|
||||
final root = _textKey.currentContext?.findRenderObject();
|
||||
final paragraphs = <RenderParagraph>[];
|
||||
if (root != null) _collectParagraphs(root, paragraphs);
|
||||
|
||||
final slices = <_ParaSlice>[];
|
||||
var offset = 0;
|
||||
for (final rp in paragraphs) {
|
||||
if (!rp.attached || !rp.hasSize) continue;
|
||||
final text = rp.text.toPlainText();
|
||||
if (_isGlyphOnly(text)) continue;
|
||||
slices.add(_ParaSlice(rp: rp, start: offset, text: text));
|
||||
offset += text.length + 1;
|
||||
}
|
||||
_cachedJoined = slices.map((s) => s.text).join('\n');
|
||||
return _cachedSlices = slices;
|
||||
}
|
||||
|
||||
String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText();
|
||||
String get _joined {
|
||||
_slices;
|
||||
return _cachedJoined;
|
||||
}
|
||||
|
||||
_ParaSlice? _sliceAt(Offset globalPos) {
|
||||
final slices = _slices;
|
||||
if (slices.isEmpty) return null;
|
||||
_ParaSlice? nearest;
|
||||
var best = double.infinity;
|
||||
for (final slice in slices) {
|
||||
final rect = slice.globalRect;
|
||||
if (rect.contains(globalPos)) return slice;
|
||||
final dy = globalPos.dy < rect.top
|
||||
? rect.top - globalPos.dy
|
||||
: globalPos.dy - rect.bottom;
|
||||
final distance = math.max(0.0, dy);
|
||||
if (distance < best) {
|
||||
best = distance;
|
||||
nearest = slice;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
int? _offsetAt(Offset globalPos) {
|
||||
final slice = _sliceAt(globalPos);
|
||||
if (slice == null) return null;
|
||||
final local = slice.rp.globalToLocal(globalPos);
|
||||
final off = slice.rp
|
||||
.getPositionForOffset(local)
|
||||
.offset
|
||||
.clamp(0, slice.text.length);
|
||||
return slice.start + off;
|
||||
}
|
||||
|
||||
RenderBox? get _rootBox {
|
||||
final ro = _textKey.currentContext?.findRenderObject();
|
||||
if (ro is! RenderBox || !ro.attached || !ro.hasSize) return null;
|
||||
return ro;
|
||||
}
|
||||
|
||||
List<Rect> _localBoxes(TextSelection selection) {
|
||||
if (!selection.isValid || selection.isCollapsed) return const [];
|
||||
final root = _rootBox;
|
||||
if (root == null) return const [];
|
||||
final rootOrigin = root.localToGlobal(Offset.zero);
|
||||
final out = <Rect>[];
|
||||
for (final slice in _slices) {
|
||||
final local = slice.clip(selection);
|
||||
if (local == null) continue;
|
||||
final delta = slice.origin - rootOrigin;
|
||||
for (final box in slice.rp.getBoxesForSelection(local)) {
|
||||
out.add(box.toRect().shift(delta));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<Rect> _globalBoxes(TextSelection selection) {
|
||||
final root = _rootBox;
|
||||
if (root == null) return const [];
|
||||
final rootOrigin = root.localToGlobal(Offset.zero);
|
||||
return [for (final rect in _localBoxes(selection)) rect.shift(rootOrigin)];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -85,12 +197,57 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
)..addListener(() => _overlay?.markNeedsBuild());
|
||||
widget.dragPosition?.addListener(_onDragPosition);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init(4));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SelectableMessageText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.dragPosition == widget.dragPosition) return;
|
||||
oldWidget.dragPosition?.removeListener(_onDragPosition);
|
||||
widget.dragPosition?.addListener(_onDragPosition);
|
||||
}
|
||||
|
||||
void _onDragPosition() {
|
||||
if (!mounted) return;
|
||||
final pos = widget.dragPosition?.value;
|
||||
if (pos == null) {
|
||||
if (_anchor != null) _toolbarVisible.value = true;
|
||||
return;
|
||||
}
|
||||
final anchor = _anchor;
|
||||
if (anchor == null) return;
|
||||
final off = _offsetAt(pos);
|
||||
if (off == null) return;
|
||||
_toolbarVisible.value = false;
|
||||
if (off > anchor.end) {
|
||||
_applySelection(
|
||||
TextSelection(baseOffset: anchor.start, extentOffset: off),
|
||||
);
|
||||
} else if (off < anchor.start) {
|
||||
_applySelection(TextSelection(baseOffset: off, extentOffset: anchor.end));
|
||||
} else {
|
||||
_applySelection(anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final position = Scrollable.maybeOf(context)?.position;
|
||||
if (identical(position, _scrollPosition)) return;
|
||||
_scrollPosition?.removeListener(_onScroll);
|
||||
_scrollPosition = position?..addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() => _overlay?.markNeedsBuild();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settle?.cancel();
|
||||
widget.dragPosition?.removeListener(_onDragPosition);
|
||||
_scrollPosition?.removeListener(_onScroll);
|
||||
_entrance.dispose();
|
||||
_overlay?.remove();
|
||||
_overlay = null;
|
||||
@@ -100,8 +257,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
|
||||
void _init(int retries) {
|
||||
if (!mounted) return;
|
||||
final rp = _paragraph;
|
||||
if (rp == null || !rp.hasSize) {
|
||||
if (_slices.isEmpty) {
|
||||
if (retries > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1));
|
||||
} else {
|
||||
@@ -109,7 +265,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
}
|
||||
return;
|
||||
}
|
||||
_selectWordAt(widget.initialGlobalPosition, rp);
|
||||
_selectWordAt(widget.initialGlobalPosition);
|
||||
_anchor = _selection;
|
||||
Haptics.selection();
|
||||
_ensureOverlay();
|
||||
_toolbarVisible.value = true;
|
||||
@@ -130,10 +287,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
});
|
||||
}
|
||||
|
||||
TextSelection _normalize(int a, int b) => TextSelection(
|
||||
baseOffset: math.min(a, b),
|
||||
extentOffset: math.max(a, b),
|
||||
);
|
||||
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;
|
||||
@@ -142,12 +297,11 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
_overlay?.markNeedsBuild();
|
||||
}
|
||||
|
||||
TextRange _wordRange(RenderParagraph rp, Offset globalPos) {
|
||||
final text = _text(rp);
|
||||
TextRange _wordRange(Offset globalPos) {
|
||||
final text = _joined;
|
||||
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);
|
||||
var off = (_offsetAt(globalPos) ?? 0).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);
|
||||
@@ -162,72 +316,59 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
return TextRange(start: s, end: e);
|
||||
}
|
||||
|
||||
void _selectWordAt(Offset globalPos, RenderParagraph rp) {
|
||||
final range = _wordRange(rp, globalPos);
|
||||
void _selectWordAt(Offset globalPos) {
|
||||
final range = _wordRange(globalPos);
|
||||
if (range.isCollapsed) {
|
||||
_applySelection(_normalize(0, _text(rp).length), animate: true);
|
||||
_applySelection(_normalize(0, _joined.length), animate: true);
|
||||
} else {
|
||||
_applySelection(_normalize(range.start, range.end), animate: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onBackgroundTap(Offset globalPos) {
|
||||
final rp = _paragraph;
|
||||
if (rp == null || !rp.hasSize) {
|
||||
final slices = _slices;
|
||||
if (slices.isEmpty) {
|
||||
_requestExit();
|
||||
return;
|
||||
}
|
||||
final local = rp.globalToLocal(globalPos);
|
||||
if (rp.size.contains(local)) {
|
||||
final inside = slices.any((s) => s.globalRect.contains(globalPos));
|
||||
if (inside) {
|
||||
_dragging = false;
|
||||
_settle?.cancel();
|
||||
_selectWordAt(globalPos, rp);
|
||||
_toolbarVisible.value = true;
|
||||
} else {
|
||||
_requestExit();
|
||||
}
|
||||
}
|
||||
|
||||
ui.TextBox? _edgeBox(RenderParagraph rp, bool isStart) {
|
||||
if (!_selection.isValid || _selection.isCollapsed) return null;
|
||||
final boxes = rp.getBoxesForSelection(_selection);
|
||||
if (boxes.isEmpty) return null;
|
||||
return isStart ? boxes.first : boxes.last;
|
||||
}
|
||||
|
||||
void _onHandleDragStart(Offset globalPos, bool isStart) {
|
||||
_dragging = true;
|
||||
_settle?.cancel();
|
||||
_entrance.value = 1.0;
|
||||
_toolbarVisible.value = false;
|
||||
_dragStartLocalY = null;
|
||||
final rp = _paragraph;
|
||||
if (rp == null || !rp.hasSize) return;
|
||||
final box = _edgeBox(rp, isStart);
|
||||
if (box == null) return;
|
||||
final rect = box.toRect();
|
||||
_dragStartLocalY = rp.globalToLocal(globalPos).dy;
|
||||
_dragStartGlobalY = null;
|
||||
final boxes = _globalBoxes(_selection);
|
||||
if (boxes.isEmpty) return;
|
||||
final rect = isStart ? boxes.first : boxes.last;
|
||||
_dragStartGlobalY = globalPos.dy;
|
||||
_dragAnchorY = rect.center.dy;
|
||||
_dragLineHeight = rect.height;
|
||||
}
|
||||
|
||||
double _lineSnappedY(double fingerLocalY) {
|
||||
final startY = _dragStartLocalY;
|
||||
if (startY == null || _dragLineHeight <= 0) return fingerLocalY;
|
||||
final dragged = fingerLocalY - startY;
|
||||
double _lineSnappedY(double fingerGlobalY) {
|
||||
final startY = _dragStartGlobalY;
|
||||
if (startY == null || _dragLineHeight <= 0) return fingerGlobalY;
|
||||
final dragged = fingerGlobalY - startY;
|
||||
final direction = dragged < 0 ? -1 : 1;
|
||||
final lines = direction * (dragged.abs() / _dragLineHeight).floor();
|
||||
return _dragAnchorY + lines * _dragLineHeight;
|
||||
}
|
||||
|
||||
void _onHandleDrag(Offset globalPos, bool isStart) {
|
||||
final rp = _paragraph;
|
||||
if (rp == null || !rp.hasSize) return;
|
||||
final len = _text(rp).length;
|
||||
final local = rp.globalToLocal(globalPos);
|
||||
final off = rp
|
||||
.getPositionForOffset(Offset(local.dx, _lineSnappedY(local.dy)))
|
||||
.offset;
|
||||
final len = _joined.length;
|
||||
if (len == 0) return;
|
||||
final off = _offsetAt(Offset(globalPos.dx, _lineSnappedY(globalPos.dy)));
|
||||
if (off == null) return;
|
||||
if (isStart) {
|
||||
final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt();
|
||||
_applySelection(
|
||||
@@ -243,7 +384,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
|
||||
void _onHandleDragEnd() {
|
||||
_dragging = false;
|
||||
_dragStartLocalY = null;
|
||||
_dragStartGlobalY = null;
|
||||
_settle?.cancel();
|
||||
_settle = Timer(const Duration(milliseconds: 140), () {
|
||||
if (!mounted || _dragging) return;
|
||||
@@ -253,9 +394,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
}
|
||||
|
||||
void _copy() {
|
||||
final rp = _paragraph;
|
||||
if (rp != null && _selection.isValid && !_selection.isCollapsed) {
|
||||
final text = _text(rp);
|
||||
if (_selection.isValid && !_selection.isCollapsed) {
|
||||
final text = _joined;
|
||||
final sub = text.substring(
|
||||
_selection.start.clamp(0, text.length),
|
||||
_selection.end.clamp(0, text.length),
|
||||
@@ -273,48 +413,53 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
}
|
||||
|
||||
void _selectAll() {
|
||||
final rp = _paragraph;
|
||||
if (rp == null) return;
|
||||
if (_slices.isEmpty) return;
|
||||
Haptics.tap();
|
||||
_applySelection(_normalize(0, _text(rp).length), animate: true);
|
||||
_applySelection(_normalize(0, _joined.length), animate: true);
|
||||
_toolbarVisible.value = true;
|
||||
}
|
||||
|
||||
Widget _buildOverlay(BuildContext ctx) {
|
||||
final rp = _paragraph;
|
||||
if (rp == null || !rp.hasSize || !rp.attached) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (_slices.isEmpty) return const SizedBox.shrink();
|
||||
final cs = Theme.of(ctx).colorScheme;
|
||||
|
||||
final List<ui.TextBox> boxes =
|
||||
(_selection.isValid && !_selection.isCollapsed)
|
||||
? rp.getBoxesForSelection(_selection)
|
||||
: const [];
|
||||
final rects = _localBoxes(_selection);
|
||||
|
||||
final children = <Widget>[
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
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));
|
||||
if (rects.isNotEmpty) {
|
||||
final first = rects.first;
|
||||
final last = rects.last;
|
||||
|
||||
children.add(_handle(cs, startBottom, isStart: true));
|
||||
children.add(_handle(cs, endBottom, isStart: false));
|
||||
children.add(_toolbar(ctx, rp, first, last));
|
||||
children.add(
|
||||
_handle(cs, Offset(first.left, first.bottom), isStart: true),
|
||||
);
|
||||
children.add(
|
||||
_handle(cs, Offset(last.right, last.bottom), isStart: false),
|
||||
);
|
||||
children.add(_toolbar(ctx, first, last));
|
||||
}
|
||||
|
||||
return Stack(children: children);
|
||||
}
|
||||
|
||||
Widget _follow(Offset offset, Widget child) => Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: CompositedTransformFollower(
|
||||
link: _link,
|
||||
showWhenUnlinked: false,
|
||||
offset: offset,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _handle(
|
||||
ColorScheme cs,
|
||||
Offset lineBottomGlobal, {
|
||||
@@ -325,41 +470,42 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
lineBottomGlobal.dy + _ballRadius,
|
||||
);
|
||||
final leftInset = isStart ? _hitOuter : _hitInner;
|
||||
return Positioned(
|
||||
left: center.dx - leftInset,
|
||||
top: center.dy - _hitAbove,
|
||||
width: _hitInner + _hitOuter,
|
||||
height: _hitAbove + _hitBelow,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
|
||||
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
|
||||
onPanEnd: (_) => _onHandleDragEnd(),
|
||||
onPanCancel: _onHandleDragEnd,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: leftInset - _ballRadius,
|
||||
top: _hitAbove - _ballRadius,
|
||||
),
|
||||
child: Transform.scale(
|
||||
scale: Curves.easeOutBack.transform(
|
||||
_entrance.value.clamp(0.0, 1.0),
|
||||
return _follow(
|
||||
Offset(center.dx - leftInset, center.dy - _hitAbove),
|
||||
SizedBox(
|
||||
width: _hitInner + _hitOuter,
|
||||
height: _hitAbove + _hitBelow,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
|
||||
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
|
||||
onPanEnd: (_) => _onHandleDragEnd(),
|
||||
onPanCancel: _onHandleDragEnd,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: leftInset - _ballRadius,
|
||||
top: _hitAbove - _ballRadius,
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -369,34 +515,36 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) {
|
||||
Widget _toolbar(BuildContext ctx, Rect first, Rect last) {
|
||||
final media = MediaQuery.of(ctx);
|
||||
final size = media.size;
|
||||
final rootOrigin = _rootBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
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;
|
||||
double top = rootOrigin.dy + first.top - gap - height;
|
||||
if (top < safeTop) top = rootOrigin.dy + last.bottom + 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)),
|
||||
return _follow(
|
||||
Offset(-rootOrigin.dx, top - rootOrigin.dy),
|
||||
SizedBox(
|
||||
width: size.width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -447,28 +595,34 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
|
||||
@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,
|
||||
return CompositedTransformTarget(
|
||||
link: _link,
|
||||
child: CustomPaint(
|
||||
painter: _HighlightPainter(
|
||||
rootKey: _textKey,
|
||||
slices: _slices,
|
||||
selection: _selection,
|
||||
animation: _entrance,
|
||||
fill: cs.primary.withValues(alpha: 0.28),
|
||||
stem: cs.primary,
|
||||
),
|
||||
child: KeyedSubtree(key: _textKey, child: widget.child),
|
||||
),
|
||||
child: KeyedSubtree(key: _textKey, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightPainter extends CustomPainter {
|
||||
final RenderParagraph? paragraph;
|
||||
final GlobalKey rootKey;
|
||||
final List<_ParaSlice> slices;
|
||||
final TextSelection selection;
|
||||
final Animation<double> animation;
|
||||
final Color fill;
|
||||
final Color stem;
|
||||
|
||||
_HighlightPainter({
|
||||
required this.paragraph,
|
||||
required this.rootKey,
|
||||
required this.slices,
|
||||
required this.selection,
|
||||
required this.animation,
|
||||
required this.fill,
|
||||
@@ -478,28 +632,36 @@ class _HighlightPainter extends CustomPainter {
|
||||
@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 root = rootKey.currentContext?.findRenderObject();
|
||||
if (root is! RenderBox || !root.attached || !root.hasSize) return;
|
||||
final rootOrigin = root.localToGlobal(Offset.zero);
|
||||
|
||||
final rects = <Rect>[];
|
||||
for (final slice in slices) {
|
||||
if (!slice.usable) continue;
|
||||
final local = slice.clip(selection);
|
||||
if (local == null) continue;
|
||||
final delta = slice.origin - rootOrigin;
|
||||
for (final box in slice.rp.getBoxesForSelection(local)) {
|
||||
rects.add(box.toRect().shift(delta));
|
||||
}
|
||||
}
|
||||
if (rects.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,
|
||||
);
|
||||
for (final rect in rects) {
|
||||
final inflated = rect.inflate(0.5);
|
||||
final cy = inflated.center.dy;
|
||||
final h = inflated.height * grow;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(animRect, const Radius.circular(3)),
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTRB(inflated.left, cy - h / 2, inflated.right, cy + h / 2),
|
||||
const Radius.circular(3),
|
||||
),
|
||||
fillPaint,
|
||||
);
|
||||
}
|
||||
@@ -508,8 +670,8 @@ class _HighlightPainter extends CustomPainter {
|
||||
..color = stem.withValues(alpha: stem.a * eased)
|
||||
..strokeWidth = 2.5
|
||||
..strokeCap = StrokeCap.round;
|
||||
final first = boxes.first.toRect();
|
||||
final last = boxes.last.toRect();
|
||||
final first = rects.first;
|
||||
final last = rects.last;
|
||||
canvas.drawLine(
|
||||
Offset(first.left, first.top),
|
||||
Offset(first.left, first.bottom),
|
||||
@@ -525,7 +687,7 @@ class _HighlightPainter extends CustomPainter {
|
||||
@override
|
||||
bool shouldRepaint(_HighlightPainter old) =>
|
||||
old.selection != selection ||
|
||||
old.paragraph != paragraph ||
|
||||
old.slices.length != slices.length ||
|
||||
old.fill != fill ||
|
||||
old.stem != stem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user