From 6f2570c5befa50b0df5ab1c7d258c7e74c7e26d5 Mon Sep 17 00:00:00 2001 From: bggRGjQaUbCoE Date: Fri, 27 Jun 2025 12:02:32 +0800 Subject: [PATCH] feat: richtextfield Signed-off-by: bggRGjQaUbCoE --- README.md | 2 +- lib/common/widgets/text_field/controller.dart | 973 +++++ .../cupertino/cupertino_text_field.dart | 132 +- lib/common/widgets/text_field/editable.dart | 3376 +++++++++++++++++ .../widgets/text_field/editable_text.dart | 241 +- lib/common/widgets/text_field/text_field.dart | 173 +- .../widgets/text_field/text_selection.dart | 1734 ++++++++- lib/http/video.dart | 4 +- lib/pages/common/common_publish_page.dart | 506 --- .../common/publish/common_publish_page.dart | 256 ++ .../publish/common_rich_text_pub_page.dart | 342 ++ .../common/publish/common_text_pub_page.dart | 29 + lib/pages/common/reply_controller.dart | 23 +- lib/pages/dynamics_create/view.dart | 34 +- lib/pages/dynamics_repost/view.dart | 38 +- lib/pages/emote/view.dart | 27 +- lib/pages/live_emote/view.dart | 10 +- lib/pages/live_room/controller.dart | 5 +- lib/pages/live_room/send_danmaku/view.dart | 25 +- lib/pages/live_room/view.dart | 12 +- lib/pages/video/controller.dart | 2 +- lib/pages/video/reply_new/view.dart | 28 +- lib/pages/video/reply_reply/controller.dart | 12 +- lib/pages/video/send_danmaku/view.dart | 10 +- lib/pages/whisper_detail/controller.dart | 5 +- lib/pages/whisper_detail/view.dart | 25 +- 26 files changed, 7154 insertions(+), 870 deletions(-) create mode 100644 lib/common/widgets/text_field/controller.dart create mode 100644 lib/common/widgets/text_field/editable.dart delete mode 100644 lib/pages/common/common_publish_page.dart create mode 100644 lib/pages/common/publish/common_publish_page.dart create mode 100644 lib/pages/common/publish/common_rich_text_pub_page.dart create mode 100644 lib/pages/common/publish/common_text_pub_page.dart diff --git a/README.md b/README.md index f0a61288..2218ecd0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ ## feat -- [x] 动态/评论@用户 +- [x] 发布动态/评论支持`富文本编辑`/`表情显示`/`@用户` - [x] 修改消息设置 - [x] 修改聊天设置 - [x] 展示折叠消息 diff --git a/lib/common/widgets/text_field/controller.dart b/lib/common/widgets/text_field/controller.dart new file mode 100644 index 00000000..77bbfb3f --- /dev/null +++ b/lib/common/widgets/text_field/controller.dart @@ -0,0 +1,973 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:math'; + +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/models/common/image_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// +/// created by bggRGjQaUbCoE on 2025/6/27 +/// + +enum RichTextType { text, composing, at, emoji } + +class Emote { + late String url; + late double width; + late double height; + + Emote({ + required this.url, + required this.width, + double? height, + }) : height = height ?? width; +} + +mixin RichTextTypeMixin { + RichTextType get type; + Emote? get emote; + String? get uid; + String? get rawText; +} + +extension TextEditingDeltaExt on TextEditingDelta { + ({RichTextType type, String? rawText, Emote? emote, String? uid}) get config { + if (this case RichTextTypeMixin e) { + return (type: e.type, rawText: e.rawText, emote: e.emote, uid: e.uid); + } + return ( + type: composing.isValid ? RichTextType.composing : RichTextType.text, + rawText: null, + emote: null, + uid: null + ); + } + + bool get isText { + if (this case RichTextTypeMixin e) { + return e.type == RichTextType.text; + } + return !composing.isValid; + } + + bool get isComposing { + return composing.isValid; + } +} + +class RichTextEditingDeltaInsertion extends TextEditingDeltaInsertion + with RichTextTypeMixin { + RichTextEditingDeltaInsertion({ + required super.oldText, + required super.textInserted, + required super.insertionOffset, + required super.selection, + required super.composing, + RichTextType? type, + this.emote, + this.uid, + this.rawText, + }) { + this.type = type ?? + (composing.isValid ? RichTextType.composing : RichTextType.text); + } + + @override + late final RichTextType type; + + @override + final Emote? emote; + + @override + final String? uid; + + @override + final String? rawText; +} + +class RichTextEditingDeltaReplacement extends TextEditingDeltaReplacement + with RichTextTypeMixin { + RichTextEditingDeltaReplacement({ + required super.oldText, + required super.replacementText, + required super.replacedRange, + required super.selection, + required super.composing, + RichTextType? type, + this.emote, + this.uid, + this.rawText, + }) { + this.type = type ?? + (composing.isValid ? RichTextType.composing : RichTextType.text); + } + + @override + late final RichTextType type; + + @override + final Emote? emote; + + @override + final String? uid; + + @override + final String? rawText; +} + +class RichTextItem { + late RichTextType type; + late String text; + String? _rawText; + late TextRange range; + Emote? emote; + String? uid; + + String get rawText => _rawText ?? text; + + bool get isText => type == RichTextType.text; + + bool get isComposing => type == RichTextType.composing; + + bool get isRich => type == RichTextType.at || type == RichTextType.emoji; + + RichTextItem({ + this.type = RichTextType.text, + required this.text, + String? rawText, + required this.range, + this.emote, + this.uid, + }) { + _rawText = rawText; + } + + RichTextItem.fromStart( + this.text, { + String? rawText, + this.type = RichTextType.text, + this.emote, + this.uid, + }) { + range = TextRange(start: 0, end: text.length); + _rawText = rawText; + } + + List? onInsert( + TextEditingDeltaInsertion delta, + RichTextEditingController controller, + ) { + final int insertionOffset = delta.insertionOffset; + + if (range.end < insertionOffset) { + return null; + } + + if (insertionOffset == 0 && range.start == 0) { + final insertedLength = delta.textInserted.length; + controller.newSelection = TextSelection.collapsed(offset: insertedLength); + if (isText && delta.isText) { + text = delta.textInserted + text; + range = TextRange(start: range.start, end: range.start + text.length); + return null; + } + range = TextRange( + start: range.start + insertedLength, + end: range.end + insertedLength, + ); + final config = delta.config; + final insertedItem = RichTextItem.fromStart( + delta.textInserted, + rawText: config.rawText, + type: config.type, + emote: config.emote, + uid: config.uid, + ); + return [insertedItem]; + } + + if (range.start >= insertionOffset) { + final int insertedLength = delta.textInserted.length; + range = TextRange( + start: range.start + insertedLength, + end: range.end + insertedLength, + ); + return null; + } + + if (range.end == insertionOffset) { + final end = insertionOffset + delta.textInserted.length; + controller.newSelection = TextSelection.collapsed(offset: end); + if ((isText && delta.isText) || (isComposing && delta.isComposing)) { + text += delta.textInserted; + range = TextRange(start: range.start, end: end); + return null; + } + final config = delta.config; + final insertedItem = RichTextItem( + type: config.type, + emote: config.emote, + uid: config.uid, + text: delta.textInserted, + rawText: config.rawText, + range: TextRange(start: insertionOffset, end: end), + ); + return [insertedItem]; + } + + if (isText && + range.start < insertionOffset && + range.end > insertionOffset) { + final leadingText = text.substring(0, insertionOffset - range.start); + final trailingString = text.substring(leadingText.length); + final insertEnd = insertionOffset + delta.textInserted.length; + controller.newSelection = TextSelection.collapsed(offset: insertEnd); + if (delta.isText) { + text = leadingText + delta.textInserted + trailingString; + range = TextRange( + start: range.start, + end: range.start + text.length, + ); + return null; + } + final config = delta.config; + final insertedItem = RichTextItem( + type: config.type, + emote: config.emote, + uid: config.uid, + text: delta.textInserted, + rawText: config.rawText, + range: TextRange(start: insertionOffset, end: insertEnd), + ); + final trailItem = RichTextItem( + text: trailingString, + range: TextRange( + start: insertEnd, + end: insertEnd + trailingString.length, + ), + ); + text = leadingText; + range = TextRange( + start: range.start, + end: range.start + leadingText.length, + ); + return [insertedItem, trailItem]; + } + + return null; + } + + ({bool remove, bool cal})? onDelete( + TextEditingDeltaDeletion delta, + RichTextEditingController controller, + int? delLength, + ) { + final deletedRange = delta.deletedRange; + + if (range.end <= deletedRange.start) { + return null; + } + + if (range.start >= deletedRange.end) { + final length = delLength ?? delta.textDeleted.length; + range = TextRange( + start: range.start - length, + end: range.end - length, + ); + return null; + } + + if (range.start < deletedRange.start && range.end > deletedRange.end) { + if (isRich) { + controller.newSelection = TextSelection.collapsed(offset: range.start); + return (remove: true, cal: true); + } + text = text.replaceRange( + deletedRange.start - range.start, + deletedRange.end - range.start, + '', + ); + range = TextRange(start: range.start, end: range.start + text.length); + controller.newSelection = + TextSelection.collapsed(offset: deletedRange.start); + return null; + } + + if (range.start >= deletedRange.start && range.end <= deletedRange.end) { + if (range.start == deletedRange.start) { + controller.newSelection = TextSelection.collapsed(offset: range.start); + } + return (remove: true, cal: false); + } + + if (range.start < deletedRange.start && range.end <= deletedRange.end) { + if (isRich) { + controller.newSelection = TextSelection.collapsed(offset: range.start); + return (remove: true, cal: true); + } + text = text.replaceRange( + text.length - (range.end - deletedRange.start), + null, + '', + ); + range = TextRange( + start: range.start, + end: deletedRange.start, + ); + controller.newSelection = + TextSelection.collapsed(offset: deletedRange.start); + return null; + } + + if (range.start >= deletedRange.start && range.end > deletedRange.end) { + final start = min(deletedRange.start, range.start); + controller.newSelection = TextSelection.collapsed(offset: start); + if (isRich) { + return (remove: true, cal: true); + } + text = text.substring(deletedRange.end - range.start); + range = TextRange( + start: start, + end: start + text.length, + ); + return null; + } + + return null; + } + + ({bool remove, List? toAdd})? onReplace( + TextEditingDeltaReplacement delta, + RichTextEditingController controller, + ) { + final replacedRange = delta.replacedRange; + + if (range.end <= replacedRange.start) { + return null; + } + + if (range.start >= replacedRange.end) { + final before = replacedRange.end - replacedRange.start; + final after = delta.replacementText.length; + final length = after - before; + range = TextRange( + start: range.start + length, + end: range.end + length, + ); + return null; + } + + if (range.start < replacedRange.start && range.end > replacedRange.end) { + if (isText) { + if (delta.isText) { + text = text.replaceRange( + replacedRange.start - range.start, + replacedRange.end - range.start, + delta.replacementText, + ); + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed( + offset: replacedRange.start + delta.replacementText.length); + return null; + } else { + final leadingText = + text.substring(0, replacedRange.start - range.start); + final trailString = text.substring(replacedRange.end - range.start); + final insertEnd = replacedRange.start + delta.replacementText.length; + controller.newSelection = TextSelection.collapsed(offset: insertEnd); + final config = delta.config; + final insertedItem = RichTextItem( + type: config.type, + emote: config.emote, + uid: config.uid, + text: delta.replacementText, + rawText: config.rawText, + range: TextRange( + start: replacedRange.start, + end: insertEnd, + ), + ); + final trailItem = RichTextItem( + text: trailString, + range: TextRange( + start: insertEnd, + end: insertEnd + trailString.length, + ), + ); + text = leadingText; + range = TextRange( + start: range.start, + end: range.start + leadingText.length, + ); + return ( + remove: false, + toAdd: [insertedItem, trailItem], + ); + } + } + final config = delta.config; + text = delta.replacementText; + type = config.type; + emote = config.emote; + uid = config.uid; + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return null; + } + + if (range.start >= replacedRange.start && range.end <= replacedRange.end) { + if (range.start == replacedRange.start) { + text = delta.replacementText; + final config = delta.config; + _rawText = config.rawText; + type = config.type; + emote = config.emote; + uid = config.uid; + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return (remove: false, toAdd: null); + } + return (remove: true, toAdd: null); + } + + if (range.start < replacedRange.start && range.end <= replacedRange.end) { + if (isText) { + if (delta.isText) { + text = text.replaceRange( + text.length - (range.end - replacedRange.start), + null, + delta.replacementText, + ); + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return null; + } else { + text = text.replaceRange( + text.length - (range.end - replacedRange.start), + null, + '', + ); + range = TextRange(start: range.start, end: range.start + text.length); + final end = replacedRange.start + delta.replacementText.length; + final config = delta.config; + final insertedItem = RichTextItem( + text: delta.replacementText, + rawText: config.rawText, + type: config.type, + emote: config.emote, + uid: config.uid, + range: TextRange(start: replacedRange.start, end: end), + ); + controller.newSelection = TextSelection.collapsed(offset: end); + return (remove: false, toAdd: [insertedItem]); + } + } + text = delta.replacementText; + final config = delta.config; + type = config.type; + emote = config.emote; + uid = config.uid; + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return null; + } + + if (range.start >= replacedRange.start && range.end > replacedRange.end) { + if (range.start > replacedRange.start) { + if (isText) { + text = text.substring(replacedRange.end - range.start); + final start = replacedRange.start + delta.replacementText.length; + range = TextRange(start: start, end: start + text.length); + return null; + } + return (remove: true, toAdd: null); + } + if (isText) { + if (delta.isText) { + text = text.replaceRange( + 0, + replacedRange.end - range.start, + delta.replacementText, + ); + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return null; + } else { + final end = range.start + delta.replacementText.length; + final config = delta.config; + final insertedItem = RichTextItem( + text: delta.replacementText, + rawText: config.rawText, + type: config.type, + emote: config.emote, + uid: config.uid, + range: TextRange(start: range.start, end: end), + ); + controller.newSelection = TextSelection.collapsed(offset: end); + text = text.substring(replacedRange.end - range.start); + range = TextRange(start: end, end: end + text.length); + return (remove: true, toAdd: [insertedItem]); + } + } + text = delta.replacementText; + final config = delta.config; + type = config.type; + emote = config.emote; + uid = config.uid; + final end = range.start + text.length; + range = TextRange(start: range.start, end: end); + controller.newSelection = TextSelection.collapsed(offset: end); + return null; + } + + return null; + } + + @override + String toString() { + return '\ntype: [${type.name}],' + 'text: [$text],' + 'rawText: [$_rawText],' + '\nrange: [$range]\n'; + } +} + +class RichTextEditingController extends TextEditingController { + RichTextEditingController({ + List? items, + this.onMention, + }) : super( + text: items != null && items.isNotEmpty + ? (StringBuffer()..writeAll(items.map((e) => e.text))).toString() + : null, + ) { + if (items != null && items.isNotEmpty) { + this.items.addAll(items); + } + } + + final VoidCallback? onMention; + + TextSelection newSelection = const TextSelection.collapsed(offset: 0); + + final List items = []; + + String get plainText { + if (items.isEmpty) { + return ''; + } + final buffer = StringBuffer(); + for (var e in items) { + buffer.write(e.text); + } + return buffer.toString(); + } + + String get rawText { + if (items.isEmpty) { + return ''; + } + final buffer = StringBuffer(); + for (var e in items) { + if (e.type == RichTextType.at) { + buffer.write(e.text); + } else { + buffer.write(e.rawText); + } + } + return buffer.toString(); + } + + void syncRichText(TextEditingDelta delta) { + if (text.isEmpty) { + items.clear(); + } + + int? addIndex; + List? toAdd; + + int? delLength; + List? toDel; + + switch (delta) { + case TextEditingDeltaInsertion e: + if (e.textInserted == '@') { + onMention?.call(); + } + if (items.isEmpty) { + final config = delta.config; + items.add( + RichTextItem.fromStart( + delta.textInserted, + rawText: config.rawText, + type: config.type, + emote: config.emote, + uid: config.uid, + ), + ); + newSelection = + TextSelection.collapsed(offset: delta.textInserted.length); + return; + } + for (int index = 0; index < items.length; index++) { + List? newItems = items[index].onInsert(e, this); + if (newItems != null) { + addIndex = (e.insertionOffset == 0 && index == 0) ? 0 : index + 1; + toAdd = newItems; + } + } + + case TextEditingDeltaDeletion e: + for (int index = 0; index < items.length; index++) { + final item = items[index]; + ({bool remove, bool cal})? res = item.onDelete(e, this, delLength); + if (res != null) { + if (res.remove) { + (toDel ??= []).add(item); + } + if (res.cal) { + delLength ??= item.text.length; + } + } + } + + case TextEditingDeltaReplacement e: + for (int index = 0; index < items.length; index++) { + final item = items[index]; + ({bool remove, List? toAdd})? res = + item.onReplace(e, this); + if (res != null) { + if (res.toAdd != null) { + addIndex = res.remove + ? index + : (e.replacedRange.start == 0 && index == 0) + ? 0 + : index + 1; + (toAdd ??= []).addAll(res.toAdd!); + } else if (res.remove) { + (toDel ??= []).add(item); + } + } + } + + case TextEditingDeltaNonTextUpdate e: + newSelection = e.selection; + if (newSelection.isCollapsed) { + final newPos = dragOffset(newSelection.base); + newSelection = newSelection.copyWith( + baseOffset: newPos.offset, extentOffset: newPos.offset); + } else { + final isNormalized = + newSelection.baseOffset < newSelection.extentOffset; + var startOffset = newSelection.start; + var endOffset = newSelection.end; + final newOffset = longPressOffset(startOffset, endOffset); + startOffset = newOffset.startOffset; + endOffset = newOffset.endOffset; + newSelection = newSelection.copyWith( + baseOffset: isNormalized ? startOffset : endOffset, + extentOffset: isNormalized ? endOffset : startOffset, + ); + } + } + + if (addIndex != null && toAdd?.isNotEmpty == true) { + items.insertAll(addIndex, toAdd!); + } + if (toDel?.isNotEmpty == true) { + for (var item in toDel!) { + items.remove(item); + } + } + } + + TextStyle? composingStyle; + TextStyle? richStyle; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + assert( + !value.composing.isValid || !withComposing || value.isComposingRangeValid, + ); + + final bool composingRegionOutOfRange = + !value.isComposingRangeValid || !withComposing; + + // if (composingRegionOutOfRange) { + // return TextSpan(style: style, text: text); + // } + + // debugPrint('$items,,\n$selection'); + + return TextSpan( + style: style, + children: items.map((e) { + switch (e.type) { + case RichTextType.text: + return TextSpan(text: e.text); + case RichTextType.composing: + composingStyle ??= style?.merge( + const TextStyle(decoration: TextDecoration.underline)) ?? + const TextStyle(decoration: TextDecoration.underline); + if (composingRegionOutOfRange) { + e.type = RichTextType.text; + } + return TextSpan( + text: e.text, + style: composingRegionOutOfRange ? null : composingStyle, + ); + case RichTextType.at: + richStyle ??= (style ?? const TextStyle()) + .copyWith(color: Theme.of(context).colorScheme.primary); + return TextSpan( + text: e.text, + style: richStyle, + ); + case RichTextType.emoji: + final emote = e.emote; + if (emote != null) { + return WidgetSpan( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: NetworkImgLayer( + src: emote.url, + width: 22, // emote.width, + height: 22, // emote.height, + type: ImageType.emote, + boxFit: BoxFit.contain, + ), + ), + ); + } + return TextSpan(text: e.text); + } + }).toList(), + ); + + // final TextStyle composingStyle = + // style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? + // const TextStyle(decoration: TextDecoration.underline); + // return TextSpan( + // style: style, + // children: [ + // TextSpan(text: value.composing.textBefore(value.text)), + // TextSpan( + // style: composingStyle, + // text: value.composing.textInside(value.text)), + // TextSpan(text: value.composing.textAfter(value.text)), + // ], + // ); + } + + @override + void clear() { + items.clear(); + super.clear(); + } + + @override + void dispose() { + items.clear(); + super.dispose(); + } + + TextPosition dragOffset(TextPosition position) { + final offset = position.offset; + for (var e in items) { + final range = e.range; + if (offset >= range.end) { + continue; + } + if (offset <= range.start) { + break; + } + if (e.isRich) { + if (offset * 2 > range.start + range.end) { + return TextPosition(offset: range.end); + } else { + return TextPosition(offset: range.start); + } + } + } + return position; + } + + int tapOffset( + int offset, { + required TextPainter textPainter, + required Offset localPos, + required Offset lastTapDownPosition, + }) { + for (var e in items) { + final range = e.range; + if (offset >= range.end) { + continue; + } + if (offset < range.start) { + break; + } + // emoji tap + if (offset == range.start) { + if (e.emote != null) { + final cloestOffset = textPainter.getClosestGlyphForOffset(localPos); + if (cloestOffset != null) { + final offsetRect = cloestOffset.graphemeClusterLayoutBounds; + final offsetRange = cloestOffset.graphemeClusterCodeUnitRange; + if (lastTapDownPosition.dx > offsetRect.right) { + return offsetRange.end; + } else { + return offsetRange.start; + } + } + } + } else { + if (e.isRich) { + if (offset * 2 > range.start + range.end) { + return range.end; + } else { + return range.start; + } + } + } + } + return offset; + } + + ({int startOffset, int endOffset}) longPressOffset( + int startOffset, + int endOffset, + ) { + for (var e in items) { + final range = e.range; + if (startOffset >= range.end) { + continue; + } + if (endOffset <= range.start) { + break; + } + late final cal = range.start + range.end; + if (startOffset > range.start && startOffset < range.end) { + if (e.isRich) { + if (startOffset * 2 > cal) { + startOffset = range.end; + } else { + startOffset = range.start; + } + } + } + if (endOffset > range.start && endOffset < range.end) { + if (e.isRich) { + if (endOffset * 2 > cal) { + endOffset = range.end; + } else { + endOffset = range.start; + } + } + } + } + return (startOffset: startOffset, endOffset: endOffset); + } + + TextSelection keyboardOffset(TextSelection newSelection) { + final offset = newSelection.baseOffset; + for (var e in items) { + final range = e.range; + if (offset >= range.end) { + continue; + } + if (offset <= range.start) { + break; + } + if (offset > range.start && offset < range.end) { + if (e.isRich) { + if (offset < value.selection.baseOffset) { + return newSelection.copyWith( + baseOffset: range.start, extentOffset: range.start); + } else { + return newSelection.copyWith( + baseOffset: range.end, extentOffset: range.end); + } + } + } + } + return newSelection; + } + + TextSelection keyboardOffsets(TextSelection newSelection) { + final startOffset = newSelection.start; + final endOffset = newSelection.end; + final isNormalized = newSelection.baseOffset < newSelection.extentOffset; + for (var e in items) { + final range = e.range; + if (startOffset >= range.end) { + continue; + } + if (endOffset <= range.start) { + break; + } + if (isNormalized) { + if (startOffset <= range.start && + endOffset > range.start && + endOffset < range.end) { + if (e.isRich) { + if (endOffset < selection.extentOffset) { + return newSelection.copyWith( + baseOffset: startOffset, + extentOffset: range.start, + ); + } else { + return newSelection.copyWith( + baseOffset: startOffset, + extentOffset: range.end, + ); + } + } + } + } else { + if (startOffset < range.end && startOffset > range.start) { + if (e.isRich) { + if (startOffset > selection.extentOffset) { + return newSelection.copyWith( + baseOffset: endOffset, + extentOffset: range.end, + ); + } else { + return newSelection.copyWith( + baseOffset: endOffset, + extentOffset: range.start, + ); + } + } + } + } + } + return newSelection; + } +} diff --git a/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart index 4780715d..25063931 100644 --- a/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart +++ b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart @@ -7,6 +7,7 @@ library; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart'; import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart'; import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; @@ -23,7 +24,8 @@ import 'package:flutter/cupertino.dart' CupertinoSpellCheckSuggestionsToolbar, CupertinoAdaptiveTextSelectionToolbar, TextSelectionGestureDetectorBuilderDelegate, - TextSelectionGestureDetectorBuilder; + TextSelectionGestureDetectorBuilder, + TextSelectionOverlay; import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; @@ -114,11 +116,11 @@ enum OverlayVisibilityMode { class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { _CupertinoTextFieldSelectionGestureDetectorBuilder( - {required _CupertinoTextFieldState state}) + {required _CupertinoRichTextFieldState state}) : _state = state, super(delegate: state); - final _CupertinoTextFieldState _state; + final _CupertinoRichTextFieldState _state; @override void onSingleTapUp(TapDragUpDetails details) { @@ -162,7 +164,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder /// {@macro flutter.widgets.EditableText.onChanged} /// /// {@tool dartpad} -/// This example shows how to set the initial value of the [CupertinoTextField] using +/// This example shows how to set the initial value of the [CupertinoRichTextField] using /// a [controller] that already contains some text. /// /// ** See code in examples/api/lib/cupertino/text_field/cupertino_text_field.0.dart ** @@ -177,18 +179,18 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder /// /// {@macro flutter.material.textfield.wantKeepAlive} /// -/// Remember to call [TextEditingController.dispose] when it is no longer +/// Remember to call [RichTextEditingController.dispose] when it is no longer /// needed. This will ensure we discard any resources used by the object. /// /// {@macro flutter.widgets.editableText.showCaretOnScreen} /// /// ## Scrolling Considerations /// -/// If this [CupertinoTextField] is not a descendant of [Scaffold] and is being +/// If this [CupertinoRichTextField] is not a descendant of [Scaffold] and is being /// used within a [Scrollable] or nested [Scrollable]s, consider placing a /// [ScrollNotificationObserver] above the root [Scrollable] that contains this -/// [CupertinoTextField] to ensure proper scroll coordination for -/// [CupertinoTextField] and its components like [TextSelectionOverlay]. +/// [CupertinoRichTextField] to ensure proper scroll coordination for +/// [CupertinoRichTextField] and its components like [TextSelectionOverlay]. /// /// See also: /// @@ -197,12 +199,12 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder /// Design UI conventions. /// * [EditableText], which is the raw text editing control at the heart of a /// [TextField]. -/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). +/// * Learn how to use a [RichTextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). /// * -class CupertinoTextField extends StatefulWidget { +class CupertinoRichTextField extends StatefulWidget { /// Creates an iOS-style text field. /// - /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// To provide a prefilled text entry, pass in a [RichTextEditingController] with /// an initial value to the [controller] parameter. /// /// To provide a hint placeholder text that appears when the text entry is @@ -238,10 +240,10 @@ class CupertinoTextField extends StatefulWidget { /// * [expands], to allow the widget to size itself to its parent's height. /// * [maxLength], which discusses the precise meaning of "number of /// characters" and how it may differ from the intuitive meaning. - const CupertinoTextField({ + const CupertinoRichTextField({ super.key, this.groupId = EditableText, - this.controller, + required this.controller, this.focusNode, this.undoController, this.decoration = _kDefaultRoundedBorderDecoration, @@ -354,7 +356,7 @@ class CupertinoTextField extends StatefulWidget { /// Creates a borderless iOS-style text field. /// - /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// To provide a prefilled text entry, pass in a [RichTextEditingController] with /// an initial value to the [controller] parameter. /// /// To provide a hint placeholder text that appears when the text entry is @@ -382,10 +384,10 @@ class CupertinoTextField extends StatefulWidget { /// * [expands], to allow the widget to size itself to its parent's height. /// * [maxLength], which discusses the precise meaning of "number of /// characters" and how it may differ from the intuitive meaning. - const CupertinoTextField.borderless({ + const CupertinoRichTextField.borderless({ super.key, this.groupId = EditableText, - this.controller, + required this.controller, this.focusNode, this.undoController, this.decoration, @@ -497,8 +499,8 @@ class CupertinoTextField extends StatefulWidget { /// Controls the text being edited. /// - /// If null, this widget will create its own [TextEditingController]. - final TextEditingController? controller; + /// If null, this widget will create its own [RichTextEditingController]. + final RichTextEditingController controller; /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; @@ -567,7 +569,7 @@ class CupertinoTextField extends StatefulWidget { /// Show an iOS-style clear button to clear the current text entry. /// /// Can be made to appear depending on various text states of the - /// [TextEditingController]. + /// [RichTextEditingController]. /// /// Will only appear if no [suffix] widget is appearing. /// @@ -882,11 +884,11 @@ class CupertinoTextField extends StatefulWidget { /// /// See also: /// * [spellCheckConfiguration], where this is typically specified for - /// [CupertinoTextField]. + /// [CupertinoRichTextField]. /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the - /// parameter for which this is the default value for [CupertinoTextField]. + /// parameter for which this is the default value for [CupertinoRichTextField]. /// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], which is like - /// this but specifies the default for [CupertinoTextField]. + /// this but specifies the default for [CupertinoRichTextField]. @visibleForTesting static Widget defaultSpellCheckSuggestionsToolbarBuilder( BuildContext context, @@ -900,13 +902,13 @@ class CupertinoTextField extends StatefulWidget { final UndoHistoryController? undoController; @override - State createState() => _CupertinoTextFieldState(); + State createState() => _CupertinoRichTextFieldState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add( - DiagnosticsProperty('controller', controller, + DiagnosticsProperty('controller', controller, defaultValue: null), ); properties.add(DiagnosticsProperty('focusNode', focusNode, @@ -1111,24 +1113,24 @@ class CupertinoTextField extends StatefulWidget { return configuration.copyWith( misspelledTextStyle: configuration.misspelledTextStyle ?? - CupertinoTextField.cupertinoMisspelledTextStyle, + CupertinoRichTextField.cupertinoMisspelledTextStyle, misspelledSelectionColor: configuration.misspelledSelectionColor ?? - CupertinoTextField.kMisspelledSelectionColor, + CupertinoRichTextField.kMisspelledSelectionColor, spellCheckSuggestionsToolbarBuilder: configuration.spellCheckSuggestionsToolbarBuilder ?? - CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + CupertinoRichTextField.defaultSpellCheckSuggestionsToolbarBuilder, ); } } -class _CupertinoTextFieldState extends State - with RestorationMixin, AutomaticKeepAliveClientMixin +class _CupertinoRichTextFieldState extends State + with RestorationMixin, AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { final GlobalKey _clearGlobalKey = GlobalKey(); - RestorableTextEditingController? _controller; - TextEditingController get _effectiveController => - widget.controller ?? _controller!.value; + // RestorableRichTextEditingController? _controller; + RichTextEditingController get _effectiveController => widget.controller; + // widget.controller ?? _controller!.value; FocusNode? _focusNode; FocusNode get _effectiveFocusNode => @@ -1162,23 +1164,23 @@ class _CupertinoTextFieldState extends State _CupertinoTextFieldSelectionGestureDetectorBuilder( state: this, ); - if (widget.controller == null) { - _createLocalController(); - } + // if (widget.controller == null) { + // _createLocalController(); + // } _effectiveFocusNode.canRequestFocus = widget.enabled; _effectiveFocusNode.addListener(_handleFocusChanged); } @override - void didUpdateWidget(CupertinoTextField oldWidget) { + void didUpdateWidget(CupertinoRichTextField oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.controller == null && oldWidget.controller != null) { - _createLocalController(oldWidget.controller!.value); - } else if (widget.controller != null && oldWidget.controller == null) { - unregisterFromRestoration(_controller!); - _controller!.dispose(); - _controller = null; - } + // if (widget.controller == null && oldWidget.controller != null) { + // _createLocalController(oldWidget.controller!.value); + // } else if (widget.controller != null && oldWidget.controller == null) { + // unregisterFromRestoration(_controller!); + // _controller!.dispose(); + // _controller = null; + // } if (widget.focusNode != oldWidget.focusNode) { (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); @@ -1189,26 +1191,26 @@ class _CupertinoTextFieldState extends State @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - if (_controller != null) { - _registerController(); - } + // if (_controller != null) { + // _registerController(); + // } } - void _registerController() { - assert(_controller != null); - registerForRestoration(_controller!, 'controller'); - _controller!.value.addListener(updateKeepAlive); - } + // void _registerController() { + // assert(_controller != null); + // registerForRestoration(_controller!, 'controller'); + // _controller!.value.addListener(updateKeepAlive); + // } - void _createLocalController([TextEditingValue? value]) { - assert(_controller == null); - _controller = value == null - ? RestorableTextEditingController() - : RestorableTextEditingController.fromValue(value); - if (!restorePending) { - _registerController(); - } - } + // void _createLocalController([TextEditingValue? value]) { + // assert(_controller == null); + // _controller = value == null + // ? RestorableRichTextEditingController() + // : RestorableRichTextEditingController.fromValue(value); + // if (!restorePending) { + // _registerController(); + // } + // } @override String? get restorationId => widget.restorationId; @@ -1217,7 +1219,7 @@ class _CupertinoTextFieldState extends State void dispose() { _effectiveFocusNode.removeListener(_handleFocusChanged); _focusNode?.dispose(); - _controller?.dispose(); + // _controller?.dispose(); super.dispose(); } @@ -1297,7 +1299,7 @@ class _CupertinoTextFieldState extends State } @override - bool get wantKeepAlive => _controller?.value.text.isNotEmpty ?? false; + bool get wantKeepAlive => _effectiveController.value.text.isNotEmpty; static bool _shouldShowAttachment({ required OverlayVisibilityMode attachment, @@ -1489,7 +1491,7 @@ class _CupertinoTextFieldState extends State Widget build(BuildContext context) { super.build(context); // See AutomaticKeepAliveClientMixin. assert(debugCheckHasDirectionality(context)); - final TextEditingController controller = _effectiveController; + final RichTextEditingController controller = _effectiveController; TextSelectionControls? textSelectionControls = widget.selectionControls; VoidCallback? handleDidGainAccessibilityFocus; @@ -1608,7 +1610,7 @@ class _CupertinoTextFieldState extends State // ensure that configuration uses Cupertino text style for misspelled words // unless a custom style is specified. final SpellCheckConfiguration spellCheckConfiguration = - CupertinoTextField.inferIOSSpellCheckConfiguration( + CupertinoRichTextField.inferIOSSpellCheckConfiguration( widget.spellCheckConfiguration); final Widget paddedEditable = Padding( @@ -1643,7 +1645,7 @@ class _CupertinoTextFieldState extends State minLines: widget.minLines, expands: widget.expands, magnifierConfiguration: widget.magnifierConfiguration ?? - CupertinoTextField._iosMagnifierConfiguration, + CupertinoRichTextField._iosMagnifierConfiguration, // Only show the selection highlight when the text field is focused. selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null, diff --git a/lib/common/widgets/text_field/editable.dart b/lib/common/widgets/text_field/editable.dart new file mode 100644 index 00000000..b750b443 --- /dev/null +++ b/lib/common/widgets/text_field/editable.dart @@ -0,0 +1,3376 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'package:flutter/cupertino.dart'; +library; + +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui' as ui + show + BoxHeightStyle, + BoxWidthStyle, + LineMetrics, + SemanticsInputType, + TextBox; + +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +const double _kCaretGap = 1.0; // pixels +const double _kCaretHeightOffset = 2.0; // pixels + +// The additional size on the x and y axis with which to expand the prototype +// cursor to render the floating cursor in pixels. +const EdgeInsets _kFloatingCursorSizeIncrease = EdgeInsets.symmetric( + horizontal: 0.5, + vertical: 1.0, +); + +// The corner radius of the floating cursor in pixels. +const Radius _kFloatingCursorRadius = Radius.circular(1.0); + +// This constant represents the shortest squared distance required between the floating cursor +// and the regular cursor when both are present in the text field. +// If the squared distance between the two cursors is less than this value, +// it's not necessary to display both cursors at the same time. +// This behavior is consistent with the one observed in iOS UITextField. +const double _kShortestDistanceSquaredWithFloatingAndRegularCursors = + 15.0 * 15.0; + +/// The consecutive sequence of [TextPosition]s that the caret should move to +/// when the user navigates the paragraph using the upward arrow key or the +/// downward arrow key. +/// +/// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement} +/// When the user presses the upward arrow key or the downward arrow key, on +/// many platforms (macOS for instance), the caret will move to the previous +/// line or the next line, while maintaining its original horizontal location. +/// When it encounters a shorter line, the caret moves to the closest horizontal +/// location within that line, and restores the original horizontal location +/// when a long enough line is encountered. +/// +/// Additionally, the caret will move to the beginning of the document if the +/// upward arrow key is pressed and the caret is already on the first line. If +/// the downward arrow key is pressed next, the caret will restore its original +/// horizontal location and move to the second line. Similarly the caret moves +/// to the end of the document if the downward arrow key is pressed when it's +/// already on the last line. +/// +/// Consider a left-aligned paragraph: +/// aa| +/// a +/// aaa +/// where the caret was initially placed at the end of the first line. Pressing +/// the downward arrow key once will move the caret to the end of the second +/// line, and twice the arrow key moves to the third line after the second "a" +/// on that line. Pressing the downward arrow key again, the caret will move to +/// the end of the third line (the end of the document). Pressing the upward +/// arrow key in this state will result in the caret moving to the end of the +/// second line. +/// +/// Vertical caret runs are typically interrupted when the layout of the text +/// changes (including when the text itself changes), or when the selection is +/// changed by other input events or programmatically (for example, when the +/// user pressed the left arrow key). +/// {@endtemplate} +/// +/// The [movePrevious] method moves the caret location (which is +/// [VerticalCaretMovementRun.current]) to the previous line, and in case +/// the caret is already on the first line, the method does nothing and returns +/// false. Similarly the [moveNext] method moves the caret to the next line, and +/// returns false if the caret is already on the last line. +/// +/// The [moveByOffset] method takes a pixel offset from the current position to move +/// the caret up or down. +/// +/// If the underlying paragraph's layout changes, [isValid] becomes false and +/// the [VerticalCaretMovementRun] must not be used. The [isValid] property must +/// be checked before calling [movePrevious], [moveNext] and [moveByOffset], +/// or accessing [current]. +class VerticalCaretMovementRun implements Iterator { + VerticalCaretMovementRun._( + this._editable, + this._lineMetrics, + this._currentTextPosition, + this._currentLine, + this._currentOffset, + ); + + Offset _currentOffset; + int _currentLine; + TextPosition _currentTextPosition; + + final List _lineMetrics; + final RenderEditable _editable; + + bool _isValid = true; + + /// Whether this [VerticalCaretMovementRun] can still continue. + /// + /// A [VerticalCaretMovementRun] run is valid if the underlying text layout + /// hasn't changed. + /// + /// The [current] value and the [movePrevious], [moveNext] and [moveByOffset] + /// methods must not be accessed when [isValid] is false. + bool get isValid { + if (!_isValid) { + return false; + } + final List newLineMetrics = + _editable._textPainter.computeLineMetrics(); + // Use the implementation detail of the computeLineMetrics method to figure + // out if the current text layout has been invalidated. + if (!identical(newLineMetrics, _lineMetrics)) { + _isValid = false; + } + return _isValid; + } + + final Map> _positionCache = + >{}; + + MapEntry _getTextPositionForLine(int lineNumber) { + assert(isValid); + assert(lineNumber >= 0); + final MapEntry? cachedPosition = + _positionCache[lineNumber]; + if (cachedPosition != null) { + return cachedPosition; + } + assert(lineNumber != _currentLine); + + final Offset newOffset = Offset( + _currentOffset.dx, + _lineMetrics[lineNumber].baseline, + ); + final TextPosition closestPosition = + _editable._textPainter.getPositionForOffset(newOffset); + final MapEntry position = + MapEntry(newOffset, closestPosition); + _positionCache[lineNumber] = position; + return position; + } + + @override + TextPosition get current { + assert(isValid); + return _currentTextPosition; + } + + @override + bool moveNext() { + assert(isValid); + if (_currentLine + 1 >= _lineMetrics.length) { + return false; + } + final MapEntry position = _getTextPositionForLine( + _currentLine + 1, + ); + _currentLine += 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } + + /// Move back to the previous element. + /// + /// Returns true and updates [current] if successful. + bool movePrevious() { + assert(isValid); + if (_currentLine <= 0) { + return false; + } + final MapEntry position = _getTextPositionForLine( + _currentLine - 1, + ); + _currentLine -= 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } + + /// Move forward or backward by a number of elements determined + /// by pixel [offset]. + /// + /// If [offset] is negative, move backward; otherwise move forward. + /// + /// Returns true and updates [current] if successful. + bool moveByOffset(double offset) { + final Offset initialOffset = _currentOffset; + if (offset >= 0.0) { + while (_currentOffset.dy < initialOffset.dy + offset) { + if (!moveNext()) { + break; + } + } + } else { + while (_currentOffset.dy > initialOffset.dy + offset) { + if (!movePrevious()) { + break; + } + } + } + return initialOffset != _currentOffset; + } +} + +/// Displays some text in a scrollable container with a potentially blinking +/// cursor and with gesture recognizers. +/// +/// This is the renderer for an editable text field. It does not directly +/// provide affordances for editing the text, but it does handle text selection +/// and manipulation of the text cursor. +/// +/// The [text] is displayed, scrolled by the given [offset], aligned according +/// to [textAlign]. The [maxLines] property controls whether the text displays +/// on one line or many. The [selection], if it is not collapsed, is painted in +/// the [selectionColor]. If it _is_ collapsed, then it represents the cursor +/// position. The cursor is shown while [showCursor] is true. It is painted in +/// the [cursorColor]. +/// +/// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value +/// to actually blink the cursor, and other features not mentioned above are the +/// responsibility of higher layers and not handled by this object. +class RenderEditable extends RenderBox + with + RelayoutWhenSystemFontsChangeMixin, + ContainerRenderObjectMixin, + RenderInlineChildrenContainerDefaults + implements TextLayoutMetrics { + /// Creates a render object that implements the visual aspects of a text field. + /// + /// The [textAlign] argument defaults to [TextAlign.start]. + /// + /// If [showCursor] is not specified, then it defaults to hiding the cursor. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. By default, it is 1, meaning this is a single-line + /// text field. If it is not null, it must be greater than zero. + /// + /// Use [ViewportOffset.zero] for the [offset] if there is no need for + /// scrolling. + RenderEditable({ + InlineSpan? text, + required TextDirection textDirection, + TextAlign textAlign = TextAlign.start, + Color? cursorColor, + Color? backgroundCursorColor, + ValueNotifier? showCursor, + bool? hasFocus, + required LayerLink startHandleLayerLink, + required LayerLink endHandleLayerLink, + int? maxLines = 1, + int? minLines, + bool expands = false, + StrutStyle? strutStyle, + Color? selectionColor, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, + TextSelection? selection, + required ViewportOffset offset, + this.ignorePointer = false, + bool readOnly = false, + bool forceLine = true, + TextHeightBehavior? textHeightBehavior, + TextWidthBasis textWidthBasis = TextWidthBasis.parent, + String obscuringCharacter = '•', + bool obscureText = false, + Locale? locale, + double cursorWidth = 1.0, + double? cursorHeight, + Radius? cursorRadius, + bool paintCursorAboveText = false, + Offset cursorOffset = Offset.zero, + double devicePixelRatio = 1.0, + ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight, + ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight, + bool? enableInteractiveSelection, + this.floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), + TextRange? promptRectRange, + Color? promptRectColor, + Clip clipBehavior = Clip.hardEdge, + required this.textSelectionDelegate, + RenderEditablePainter? painter, + RenderEditablePainter? foregroundPainter, + List? children, + required this.controller, + }) : assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert( + identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + assert(obscuringCharacter.characters.length == 1), + assert(cursorWidth >= 0.0), + assert(cursorHeight == null || cursorHeight >= 0.0), + _textPainter = TextPainter( + text: text, + textAlign: textAlign, + textDirection: textDirection, + textScaler: textScaler == TextScaler.noScaling + ? TextScaler.linear(textScaleFactor) + : textScaler, + locale: locale, + maxLines: maxLines == 1 ? 1 : null, + strutStyle: strutStyle, + textHeightBehavior: textHeightBehavior, + textWidthBasis: textWidthBasis, + ), + _showCursor = showCursor ?? ValueNotifier(false), + _maxLines = maxLines, + _minLines = minLines, + _expands = expands, + _selection = selection, + _offset = offset, + _cursorWidth = cursorWidth, + _cursorHeight = cursorHeight, + _paintCursorOnTop = paintCursorAboveText, + _enableInteractiveSelection = enableInteractiveSelection, + _devicePixelRatio = devicePixelRatio, + _startHandleLayerLink = startHandleLayerLink, + _endHandleLayerLink = endHandleLayerLink, + _obscuringCharacter = obscuringCharacter, + _obscureText = obscureText, + _readOnly = readOnly, + _forceLine = forceLine, + _clipBehavior = clipBehavior, + _hasFocus = hasFocus ?? false, + _disposeShowCursor = showCursor == null { + assert(!_showCursor.value || cursorColor != null); + + _selectionPainter.highlightColor = selectionColor; + _selectionPainter.highlightedRange = selection; + _selectionPainter.selectionHeightStyle = selectionHeightStyle; + _selectionPainter.selectionWidthStyle = selectionWidthStyle; + + _autocorrectHighlightPainter.highlightColor = promptRectColor; + _autocorrectHighlightPainter.highlightedRange = promptRectRange; + + _caretPainter.caretColor = cursorColor; + _caretPainter.cursorRadius = cursorRadius; + _caretPainter.cursorOffset = cursorOffset; + _caretPainter.backgroundCursorColor = backgroundCursorColor; + + _updateForegroundPainter(foregroundPainter); + _updatePainter(painter); + addAll(children); + } + + final RichTextEditingController controller; + + /// Child render objects + _RenderEditableCustomPaint? _foregroundRenderObject; + _RenderEditableCustomPaint? _backgroundRenderObject; + + @override + void dispose() { + _leaderLayerHandler.layer = null; + _foregroundRenderObject?.dispose(); + _foregroundRenderObject = null; + _backgroundRenderObject?.dispose(); + _backgroundRenderObject = null; + _clipRectLayer.layer = null; + _cachedBuiltInForegroundPainters?.dispose(); + _cachedBuiltInPainters?.dispose(); + _selectionStartInViewport.dispose(); + _selectionEndInViewport.dispose(); + _autocorrectHighlightPainter.dispose(); + _selectionPainter.dispose(); + _caretPainter.dispose(); + _textPainter.dispose(); + _textIntrinsicsCache?.dispose(); + if (_disposeShowCursor) { + _showCursor.dispose(); + _disposeShowCursor = false; + } + super.dispose(); + } + + void _updateForegroundPainter(RenderEditablePainter? newPainter) { + final _CompositeRenderEditablePainter effectivePainter = newPainter == null + ? _builtInForegroundPainters + : _CompositeRenderEditablePainter( + painters: [ + _builtInForegroundPainters, + newPainter, + ], + ); + + if (_foregroundRenderObject == null) { + final _RenderEditableCustomPaint foregroundRenderObject = + _RenderEditableCustomPaint(painter: effectivePainter); + adoptChild(foregroundRenderObject); + _foregroundRenderObject = foregroundRenderObject; + } else { + _foregroundRenderObject?.painter = effectivePainter; + } + _foregroundPainter = newPainter; + } + + /// The [RenderEditablePainter] to use for painting above this + /// [RenderEditable]'s text content. + /// + /// The new [RenderEditablePainter] will replace the previously specified + /// foreground painter, and schedule a repaint if the new painter's + /// `shouldRepaint` method returns true. + RenderEditablePainter? get foregroundPainter => _foregroundPainter; + RenderEditablePainter? _foregroundPainter; + set foregroundPainter(RenderEditablePainter? newPainter) { + if (newPainter == _foregroundPainter) { + return; + } + _updateForegroundPainter(newPainter); + } + + void _updatePainter(RenderEditablePainter? newPainter) { + final _CompositeRenderEditablePainter effectivePainter = newPainter == null + ? _builtInPainters + : _CompositeRenderEditablePainter( + painters: [_builtInPainters, newPainter], + ); + + if (_backgroundRenderObject == null) { + final _RenderEditableCustomPaint backgroundRenderObject = + _RenderEditableCustomPaint(painter: effectivePainter); + adoptChild(backgroundRenderObject); + _backgroundRenderObject = backgroundRenderObject; + } else { + _backgroundRenderObject?.painter = effectivePainter; + } + _painter = newPainter; + } + + /// Sets the [RenderEditablePainter] to use for painting beneath this + /// [RenderEditable]'s text content. + /// + /// The new [RenderEditablePainter] will replace the previously specified + /// painter, and schedule a repaint if the new painter's `shouldRepaint` + /// method returns true. + RenderEditablePainter? get painter => _painter; + RenderEditablePainter? _painter; + set painter(RenderEditablePainter? newPainter) { + if (newPainter == _painter) { + return; + } + _updatePainter(newPainter); + } + + // Caret Painters: + // A single painter for both the regular caret and the floating cursor. + late final _CaretPainter _caretPainter = _CaretPainter(); + + // Text Highlight painters: + final _TextHighlightPainter _selectionPainter = _TextHighlightPainter(); + final _TextHighlightPainter _autocorrectHighlightPainter = + _TextHighlightPainter(); + + _CompositeRenderEditablePainter get _builtInForegroundPainters => + _cachedBuiltInForegroundPainters ??= _createBuiltInForegroundPainters(); + _CompositeRenderEditablePainter? _cachedBuiltInForegroundPainters; + _CompositeRenderEditablePainter _createBuiltInForegroundPainters() { + return _CompositeRenderEditablePainter( + painters: [ + if (paintCursorAboveText) _caretPainter, + ], + ); + } + + _CompositeRenderEditablePainter get _builtInPainters => + _cachedBuiltInPainters ??= _createBuiltInPainters(); + _CompositeRenderEditablePainter? _cachedBuiltInPainters; + _CompositeRenderEditablePainter _createBuiltInPainters() { + return _CompositeRenderEditablePainter( + painters: [ + _autocorrectHighlightPainter, + _selectionPainter, + if (!paintCursorAboveText) _caretPainter, + ], + ); + } + + /// Whether the [handleEvent] will propagate pointer events to selection + /// handlers. + /// + /// If this property is true, the [handleEvent] assumes that this renderer + /// will be notified of input gestures via [handleTapDown], [handleTap], + /// [handleDoubleTap], and [handleLongPress]. + /// + /// If there are any gesture recognizers in the text span, the [handleEvent] + /// will still propagate pointer events to those recognizers. + /// + /// The default value of this property is false. + bool ignorePointer; + + /// {@macro dart.ui.textHeightBehavior} + TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; + set textHeightBehavior(TextHeightBehavior? value) { + if (_textPainter.textHeightBehavior == value) { + return; + } + _textPainter.textHeightBehavior = value; + markNeedsLayout(); + } + + /// {@macro flutter.painting.textPainter.textWidthBasis} + TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; + set textWidthBasis(TextWidthBasis value) { + if (_textPainter.textWidthBasis == value) { + return; + } + _textPainter.textWidthBasis = value; + markNeedsLayout(); + } + + /// The pixel ratio of the current device. + /// + /// Should be obtained by querying MediaQuery for the devicePixelRatio. + double get devicePixelRatio => _devicePixelRatio; + double _devicePixelRatio; + set devicePixelRatio(double value) { + if (devicePixelRatio == value) { + return; + } + _devicePixelRatio = value; + markNeedsLayout(); + } + + /// Character used for obscuring text if [obscureText] is true. + /// + /// Must have a length of exactly one. + String get obscuringCharacter => _obscuringCharacter; + String _obscuringCharacter; + set obscuringCharacter(String value) { + if (_obscuringCharacter == value) { + return; + } + assert(value.characters.length == 1); + _obscuringCharacter = value; + markNeedsLayout(); + } + + /// Whether to hide the text being edited (e.g., for passwords). + bool get obscureText => _obscureText; + bool _obscureText; + set obscureText(bool value) { + if (_obscureText == value) { + return; + } + _obscureText = value; + _cachedAttributedValue = null; + markNeedsSemanticsUpdate(); + } + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + ui.BoxHeightStyle get selectionHeightStyle => + _selectionPainter.selectionHeightStyle; + set selectionHeightStyle(ui.BoxHeightStyle value) { + _selectionPainter.selectionHeightStyle = value; + } + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + ui.BoxWidthStyle get selectionWidthStyle => + _selectionPainter.selectionWidthStyle; + set selectionWidthStyle(ui.BoxWidthStyle value) { + _selectionPainter.selectionWidthStyle = value; + } + + /// The object that controls the text selection, used by this render object + /// for implementing cut, copy, and paste keyboard shortcuts. + /// + /// It will make cut, copy and paste functionality work with the most recently + /// set [TextSelectionDelegate]. + TextSelectionDelegate textSelectionDelegate; + + /// Track whether position of the start of the selected text is within the viewport. + /// + /// For example, if the text contains "Hello World", and the user selects + /// "Hello", then scrolls so only "World" is visible, this will become false. + /// If the user scrolls back so that the "H" is visible again, this will + /// become true. + /// + /// This bool indicates whether the text is scrolled so that the handle is + /// inside the text field viewport, as opposed to whether it is actually + /// visible on the screen. + ValueListenable get selectionStartInViewport => + _selectionStartInViewport; + final ValueNotifier _selectionStartInViewport = ValueNotifier( + true, + ); + + /// Track whether position of the end of the selected text is within the viewport. + /// + /// For example, if the text contains "Hello World", and the user selects + /// "World", then scrolls so only "Hello" is visible, this will become + /// 'false'. If the user scrolls back so that the "d" is visible again, this + /// will become 'true'. + /// + /// This bool indicates whether the text is scrolled so that the handle is + /// inside the text field viewport, as opposed to whether it is actually + /// visible on the screen. + ValueListenable get selectionEndInViewport => _selectionEndInViewport; + final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + + /// Returns the TextPosition above or below the given offset. + TextPosition _getTextPositionVertical( + TextPosition position, + double verticalOffset, + ) { + final Offset caretOffset = _textPainter.getOffsetForCaret( + position, + _caretPrototype, + ); + final Offset caretOffsetTranslated = caretOffset.translate( + 0.0, + verticalOffset, + ); + return _textPainter.getPositionForOffset(caretOffsetTranslated); + } + + // Start TextLayoutMetrics. + + /// {@macro flutter.services.TextLayoutMetrics.getLineAtOffset} + @override + TextSelection getLineAtOffset(TextPosition position) { + final TextRange line = _textPainter.getLineBoundary(position); + // If text is obscured, the entire string should be treated as one line. + if (obscureText) { + return TextSelection(baseOffset: 0, extentOffset: plainText.length); + } + return TextSelection(baseOffset: line.start, extentOffset: line.end); + } + + /// {@macro flutter.painting.TextPainter.getWordBoundary} + @override + TextRange getWordBoundary(TextPosition position) { + return _textPainter.getWordBoundary(position); + } + + /// {@macro flutter.services.TextLayoutMetrics.getTextPositionAbove} + @override + TextPosition getTextPositionAbove(TextPosition position) { + // The caret offset gives a location in the upper left hand corner of + // the caret so the middle of the line above is a half line above that + // point and the line below is 1.5 lines below that point. + final double preferredLineHeight = _textPainter.preferredLineHeight; + final double verticalOffset = -0.5 * preferredLineHeight; + return _getTextPositionVertical(position, verticalOffset); + } + + /// {@macro flutter.services.TextLayoutMetrics.getTextPositionBelow} + @override + TextPosition getTextPositionBelow(TextPosition position) { + // The caret offset gives a location in the upper left hand corner of + // the caret so the middle of the line above is a half line above that + // point and the line below is 1.5 lines below that point. + final double preferredLineHeight = _textPainter.preferredLineHeight; + final double verticalOffset = 1.5 * preferredLineHeight; + return _getTextPositionVertical(position, verticalOffset); + } + + // End TextLayoutMetrics. + + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { + assert(selection != null); + if (!selection!.isValid) { + _selectionStartInViewport.value = false; + _selectionEndInViewport.value = false; + return; + } + final Rect visibleRegion = Offset.zero & size; + + final Offset startOffset = _textPainter.getOffsetForCaret( + TextPosition(offset: selection!.start, affinity: selection!.affinity), + _caretPrototype, + ); + // Check if the selection is visible with an approximation because a + // difference between rounded and unrounded values causes the caret to be + // reported as having a slightly (< 0.5) negative y offset. This rounding + // happens in paragraph.cc's layout and TextPainter's + // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and + // this can be changed to be a strict check instead of an approximation. + const double visibleRegionSlop = 0.5; + _selectionStartInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(startOffset + effectiveOffset); + + final Offset endOffset = _textPainter.getOffsetForCaret( + TextPosition(offset: selection!.end, affinity: selection!.affinity), + _caretPrototype, + ); + _selectionEndInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(endOffset + effectiveOffset); + } + + void _setTextEditingValue( + TextEditingValue newValue, + SelectionChangedCause cause, + ) { + textSelectionDelegate.userUpdateTextEditingValue(newValue, cause); + } + + void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) { + if (nextSelection.isValid) { + // The nextSelection is calculated based on plainText, which can be out + // of sync with the textSelectionDelegate.textEditingValue by one frame. + // This is due to the render editable and editable text handle pointer + // event separately. If the editable text changes the text during the + // event handler, the render editable will use the outdated text stored in + // the plainText when handling the pointer event. + // + // If this happens, we need to make sure the new selection is still valid. + final int textLength = textSelectionDelegate.textEditingValue.text.length; + nextSelection = nextSelection.copyWith( + baseOffset: math.min(nextSelection.baseOffset, textLength), + extentOffset: math.min(nextSelection.extentOffset, textLength), + ); + } + _setTextEditingValue( + textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection), + cause, + ); + } + + @override + void markNeedsPaint() { + super.markNeedsPaint(); + // Tell the painters to repaint since text layout may have changed. + _foregroundRenderObject?.markNeedsPaint(); + _backgroundRenderObject?.markNeedsPaint(); + } + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + _textPainter.markNeedsLayout(); + } + + /// Returns a plain text version of the text in [TextPainter]. + /// + /// If [obscureText] is true, returns the obscured text. See + /// [obscureText] and [obscuringCharacter]. + /// In order to get the styled text as an [InlineSpan] tree, use [text]. + String get plainText => _textPainter.plainText; + + /// The text to paint in the form of a tree of [InlineSpan]s. + /// + /// In order to get the plain text representation, use [plainText]. + InlineSpan? get text => _textPainter.text; + final TextPainter _textPainter; + AttributedString? _cachedAttributedValue; + List? _cachedCombinedSemanticsInfos; + set text(InlineSpan? value) { + if (_textPainter.text == value) { + return; + } + _cachedLineBreakCount = null; + _textPainter.text = value; + _cachedAttributedValue = null; + _cachedCombinedSemanticsInfos = null; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + TextPainter? _textIntrinsicsCache; + TextPainter get _textIntrinsics { + return (_textIntrinsicsCache ??= TextPainter()) + ..text = _textPainter.text + ..textAlign = _textPainter.textAlign + ..textDirection = _textPainter.textDirection + ..textScaler = _textPainter.textScaler + ..maxLines = _textPainter.maxLines + ..ellipsis = _textPainter.ellipsis + ..locale = _textPainter.locale + ..strutStyle = _textPainter.strutStyle + ..textWidthBasis = _textPainter.textWidthBasis + ..textHeightBehavior = _textPainter.textHeightBehavior; + } + + /// How the text should be aligned horizontally. + TextAlign get textAlign => _textPainter.textAlign; + set textAlign(TextAlign value) { + if (_textPainter.textAlign == value) { + return; + } + _textPainter.textAlign = value; + markNeedsLayout(); + } + + /// The directionality of the text. + /// + /// This decides how the [TextAlign.start], [TextAlign.end], and + /// [TextAlign.justify] values of [textAlign] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [text] is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + // TextPainter.textDirection is nullable, but it is set to a + // non-null value in the RenderEditable constructor and we refuse to + // set it to null here, so _textPainter.textDirection cannot be null. + TextDirection get textDirection => _textPainter.textDirection!; + set textDirection(TextDirection value) { + if (_textPainter.textDirection == value) { + return; + } + _textPainter.textDirection = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + /// Used by this renderer's internal [TextPainter] to select a locale-specific + /// font. + /// + /// In some cases the same Unicode character may be rendered differently depending + /// on the locale. For example the '骨' character is rendered differently in + /// the Chinese and Japanese locales. In these cases the [locale] may be used + /// to select a locale-specific font. + /// + /// If this value is null, a system-dependent algorithm is used to select + /// the font. + Locale? get locale => _textPainter.locale; + set locale(Locale? value) { + if (_textPainter.locale == value) { + return; + } + _textPainter.locale = value; + markNeedsLayout(); + } + + /// The [StrutStyle] used by the renderer's internal [TextPainter] to + /// determine the strut to use. + StrutStyle? get strutStyle => _textPainter.strutStyle; + set strutStyle(StrutStyle? value) { + if (_textPainter.strutStyle == value) { + return; + } + _textPainter.strutStyle = value; + markNeedsLayout(); + } + + /// The color to use when painting the cursor. + Color? get cursorColor => _caretPainter.caretColor; + set cursorColor(Color? value) { + _caretPainter.caretColor = value; + } + + /// The color to use when painting the cursor aligned to the text while + /// rendering the floating cursor. + /// + /// Typically this would be set to [CupertinoColors.inactiveGray]. + /// + /// If this is null, the background cursor is not painted. + /// + /// See also: + /// + /// * [FloatingCursorDragState], which explains the floating cursor feature + /// in detail. + Color? get backgroundCursorColor => _caretPainter.backgroundCursorColor; + set backgroundCursorColor(Color? value) { + _caretPainter.backgroundCursorColor = value; + } + + bool _disposeShowCursor; + + /// Whether to paint the cursor. + ValueNotifier get showCursor => _showCursor; + ValueNotifier _showCursor; + set showCursor(ValueNotifier value) { + if (_showCursor == value) { + return; + } + if (attached) { + _showCursor.removeListener(_showHideCursor); + } + if (_disposeShowCursor) { + _showCursor.dispose(); + _disposeShowCursor = false; + } + _showCursor = value; + if (attached) { + _showHideCursor(); + _showCursor.addListener(_showHideCursor); + } + } + + void _showHideCursor() { + _caretPainter.shouldPaint = showCursor.value; + } + + /// Whether the editable is currently focused. + bool get hasFocus => _hasFocus; + bool _hasFocus = false; + set hasFocus(bool value) { + if (_hasFocus == value) { + return; + } + _hasFocus = value; + markNeedsSemanticsUpdate(); + } + + /// Whether this rendering object will take a full line regardless the text width. + bool get forceLine => _forceLine; + bool _forceLine = false; + set forceLine(bool value) { + if (_forceLine == value) { + return; + } + _forceLine = value; + markNeedsLayout(); + } + + /// Whether this rendering object is read only. + bool get readOnly => _readOnly; + bool _readOnly = false; + set readOnly(bool value) { + if (_readOnly == value) { + return; + } + _readOnly = value; + markNeedsSemanticsUpdate(); + } + + /// The maximum number of lines for the text to span, wrapping if necessary. + /// + /// If this is 1 (the default), the text will not wrap, but will extend + /// indefinitely instead. + /// + /// If this is null, there is no limit to the number of lines. + /// + /// When this is not null, the intrinsic height of the render object is the + /// height of one line of text multiplied by this value. In other words, this + /// also controls the height of the actual editing widget. + int? get maxLines => _maxLines; + int? _maxLines; + + /// The value may be null. If it is not null, then it must be greater than zero. + set maxLines(int? value) { + assert(value == null || value > 0); + if (maxLines == value) { + return; + } + _maxLines = value; + + // Special case maxLines == 1 to keep only the first line so we can get the + // height of the first line in case there are hard line breaks in the text. + // See the `_preferredHeight` method. + _textPainter.maxLines = value == 1 ? 1 : null; + markNeedsLayout(); + } + + /// {@macro flutter.widgets.editableText.minLines} + int? get minLines => _minLines; + int? _minLines; + + /// The value may be null. If it is not null, then it must be greater than zero. + set minLines(int? value) { + assert(value == null || value > 0); + if (minLines == value) { + return; + } + _minLines = value; + markNeedsLayout(); + } + + /// {@macro flutter.widgets.editableText.expands} + bool get expands => _expands; + bool _expands; + set expands(bool value) { + if (expands == value) { + return; + } + _expands = value; + markNeedsLayout(); + } + + /// The color to use when painting the selection. + Color? get selectionColor => _selectionPainter.highlightColor; + set selectionColor(Color? value) { + _selectionPainter.highlightColor = value; + } + + /// Deprecated. Will be removed in a future version of Flutter. Use + /// [textScaler] instead. + /// + /// The number of font pixels for each logical pixel. + /// + /// For example, if the text scale factor is 1.5, text will be 50% larger than + /// the specified font size. + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + double get textScaleFactor => _textPainter.textScaleFactor; + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + set textScaleFactor(double value) { + textScaler = TextScaler.linear(value); + } + + /// {@macro flutter.painting.textPainter.textScaler} + TextScaler get textScaler => _textPainter.textScaler; + set textScaler(TextScaler value) { + if (_textPainter.textScaler == value) { + return; + } + _textPainter.textScaler = value; + markNeedsLayout(); + } + + /// The region of text that is selected, if any. + /// + /// The caret position is represented by a collapsed selection. + /// + /// If [selection] is null, there is no selection and attempts to + /// manipulate the selection will throw. + TextSelection? get selection => _selection; + TextSelection? _selection; + set selection(TextSelection? value) { + if (_selection == value) { + return; + } + _selection = value; + _selectionPainter.highlightedRange = value; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + /// The offset at which the text should be painted. + /// + /// If the text content is larger than the editable line itself, the editable + /// line clips the text. This property controls which part of the text is + /// visible by shifting the text by the given offset before clipping. + ViewportOffset get offset => _offset; + ViewportOffset _offset; + set offset(ViewportOffset value) { + if (_offset == value) { + return; + } + if (attached) { + _offset.removeListener(markNeedsPaint); + } + _offset = value; + if (attached) { + _offset.addListener(markNeedsPaint); + } + markNeedsLayout(); + } + + /// How thick the cursor will be. + double get cursorWidth => _cursorWidth; + double _cursorWidth = 1.0; + set cursorWidth(double value) { + if (_cursorWidth == value) { + return; + } + _cursorWidth = value; + markNeedsLayout(); + } + + /// How tall the cursor will be. + /// + /// This can be null, in which case the getter will actually return [preferredLineHeight]. + /// + /// Setting this to itself fixes the value to the current [preferredLineHeight]. Setting + /// this to null returns the behavior of deferring to [preferredLineHeight]. + // TODO(ianh): This is a confusing API. We should have a separate getter for the effective cursor height. + double get cursorHeight => _cursorHeight ?? preferredLineHeight; + double? _cursorHeight; + set cursorHeight(double? value) { + if (_cursorHeight == value) { + return; + } + _cursorHeight = value; + markNeedsLayout(); + } + + /// {@template flutter.rendering.RenderEditable.paintCursorAboveText} + /// If the cursor should be painted on top of the text or underneath it. + /// + /// By default, the cursor should be painted on top for iOS platforms and + /// underneath for Android platforms. + /// {@endtemplate} + bool get paintCursorAboveText => _paintCursorOnTop; + bool _paintCursorOnTop; + set paintCursorAboveText(bool value) { + if (_paintCursorOnTop == value) { + return; + } + _paintCursorOnTop = value; + // Clear cached built-in painters and reconfigure painters. + _cachedBuiltInForegroundPainters = null; + _cachedBuiltInPainters = null; + // Call update methods to rebuild and set the effective painters. + _updateForegroundPainter(_foregroundPainter); + _updatePainter(_painter); + } + + /// {@template flutter.rendering.RenderEditable.cursorOffset} + /// The offset that is used, in pixels, when painting the cursor on screen. + /// + /// By default, the cursor position should be set to an offset of + /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android + /// platforms. The origin from where the offset is applied to is the arbitrary + /// location where the cursor ends up being rendered from by default. + /// {@endtemplate} + Offset get cursorOffset => _caretPainter.cursorOffset; + set cursorOffset(Offset value) { + _caretPainter.cursorOffset = value; + } + + /// How rounded the corners of the cursor should be. + /// + /// A null value is the same as [Radius.zero]. + Radius? get cursorRadius => _caretPainter.cursorRadius; + set cursorRadius(Radius? value) { + _caretPainter.cursorRadius = value; + } + + /// The [LayerLink] of start selection handle. + /// + /// [RenderEditable] is responsible for calculating the [Offset] of this + /// [LayerLink], which will be used as [CompositedTransformTarget] of start handle. + LayerLink get startHandleLayerLink => _startHandleLayerLink; + LayerLink _startHandleLayerLink; + set startHandleLayerLink(LayerLink value) { + if (_startHandleLayerLink == value) { + return; + } + _startHandleLayerLink = value; + markNeedsPaint(); + } + + /// The [LayerLink] of end selection handle. + /// + /// [RenderEditable] is responsible for calculating the [Offset] of this + /// [LayerLink], which will be used as [CompositedTransformTarget] of end handle. + LayerLink get endHandleLayerLink => _endHandleLayerLink; + LayerLink _endHandleLayerLink; + set endHandleLayerLink(LayerLink value) { + if (_endHandleLayerLink == value) { + return; + } + _endHandleLayerLink = value; + markNeedsPaint(); + } + + /// The padding applied to text field. Used to determine the bounds when + /// moving the floating cursor. + /// + /// Defaults to a padding with left, top and right set to 4, bottom to 5. + /// + /// See also: + /// + /// * [FloatingCursorDragState], which explains the floating cursor feature + /// in detail. + EdgeInsets floatingCursorAddedMargin; + + /// Returns true if the floating cursor is visible, false otherwise. + bool get floatingCursorOn => _floatingCursorOn; + bool _floatingCursorOn = false; + late TextPosition _floatingCursorTextPosition; + + /// Whether to allow the user to change the selection. + /// + /// Since [RenderEditable] does not handle selection manipulation + /// itself, this actually only affects whether the accessibility + /// hints provided to the system (via + /// [describeSemanticsConfiguration]) will enable selection + /// manipulation. It's the responsibility of this object's owner + /// to provide selection manipulation affordances. + /// + /// This field is used by [selectionEnabled] (which then controls + /// the accessibility hints mentioned above). When null, + /// [obscureText] is used to determine the value of + /// [selectionEnabled] instead. + bool? get enableInteractiveSelection => _enableInteractiveSelection; + bool? _enableInteractiveSelection; + set enableInteractiveSelection(bool? value) { + if (_enableInteractiveSelection == value) { + return; + } + _enableInteractiveSelection = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + /// Whether interactive selection are enabled based on the values of + /// [enableInteractiveSelection] and [obscureText]. + /// + /// Since [RenderEditable] does not handle selection manipulation + /// itself, this actually only affects whether the accessibility + /// hints provided to the system (via + /// [describeSemanticsConfiguration]) will enable selection + /// manipulation. It's the responsibility of this object's owner + /// to provide selection manipulation affordances. + /// + /// By default, [enableInteractiveSelection] is null, [obscureText] is false, + /// and this getter returns true. + /// + /// If [enableInteractiveSelection] is null and [obscureText] is true, then this + /// getter returns false. This is the common case for password fields. + /// + /// If [enableInteractiveSelection] is non-null then its value is + /// returned. An application might [enableInteractiveSelection] to + /// true to enable interactive selection for a password field, or to + /// false to unconditionally disable interactive selection. + bool get selectionEnabled { + return enableInteractiveSelection ?? !obscureText; + } + + /// The color used to paint the prompt rectangle. + /// + /// The prompt rectangle will only be requested on non-web iOS applications. + // TODO(ianh): We should change the getter to return null when _promptRectRange is null + // (otherwise, if you set it to null and then get it, you get back non-null). + // Alternatively, we could stop supporting setting this to null. + Color? get promptRectColor => _autocorrectHighlightPainter.highlightColor; + set promptRectColor(Color? newValue) { + _autocorrectHighlightPainter.highlightColor = newValue; + } + + /// Dismisses the currently displayed prompt rectangle and displays a new prompt rectangle + /// over [newRange] in the given color [promptRectColor]. + /// + /// The prompt rectangle will only be requested on non-web iOS applications. + /// + /// When set to null, the currently displayed prompt rectangle (if any) will be dismissed. + // ignore: use_setters_to_change_properties, (API predates enforcing the lint) + void setPromptRectRange(TextRange? newRange) { + _autocorrectHighlightPainter.highlightedRange = newRange; + } + + /// The maximum amount the text is allowed to scroll. + /// + /// This value is only valid after layout and can change as additional + /// text is entered or removed in order to accommodate expanding when + /// [expands] is set to true. + double get maxScrollExtent => _maxScrollExtent; + double _maxScrollExtent = 0; + + double get _caretMargin => _kCaretGap + cursorWidth; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + Clip get clipBehavior => _clipBehavior; + Clip _clipBehavior = Clip.hardEdge; + set clipBehavior(Clip value) { + if (value != _clipBehavior) { + _clipBehavior = value; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + /// Collected during [describeSemanticsConfiguration], used by + /// [assembleSemanticsNode]. + List? _semanticsInfo; + + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they + // can be re-used when [assembleSemanticsNode] is called again. This ensures + // stable ids for the [SemanticsNode]s of [TextSpan]s across + // [assembleSemanticsNode] invocations. + LinkedHashMap? _cachedChildNodes; + + /// Returns a list of rects that bound the given selection, and the text + /// direction. The text direction is used by the engine to calculate + /// the closest position to a given point. + /// + /// See [TextPainter.getBoxesForSelection] for more details. + List getBoxesForSelection(TextSelection selection) { + _computeTextMetricsIfNeeded(); + return _textPainter + .getBoxesForSelection(selection) + .map( + (TextBox textBox) => TextBox.fromLTRBD( + textBox.left + _paintOffset.dx, + textBox.top + _paintOffset.dy, + textBox.right + _paintOffset.dx, + textBox.bottom + _paintOffset.dy, + textBox.direction, + ), + ) + .toList(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + _semanticsInfo = _textPainter.text!.getSemanticsInformation(); + // TODO(chunhtai): the macOS does not provide a public API to support text + // selections across multiple semantics nodes. Remove this platform check + // once we can support it. + // https://github.com/flutter/flutter/issues/77957 + if (_semanticsInfo!.any( + (InlineSpanSemanticsInformation info) => info.recognizer != null, + ) && + defaultTargetPlatform != TargetPlatform.macOS) { + // assert(readOnly && !obscureText); + // For Selectable rich text with recognizer, we need to create a semantics + // node for each text fragment. + config + ..isSemanticBoundary = true + ..explicitChildNodes = true; + return; + } + if (_cachedAttributedValue == null) { + if (obscureText) { + _cachedAttributedValue = AttributedString( + obscuringCharacter * plainText.length, + ); + } else { + final StringBuffer buffer = StringBuffer(); + int offset = 0; + final List attributes = []; + for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { + final String label = info.semanticsLabel ?? info.text; + for (final StringAttribute infoAttribute in info.stringAttributes) { + final TextRange originalRange = infoAttribute.range; + attributes.add( + infoAttribute.copy( + range: TextRange( + start: offset + originalRange.start, + end: offset + originalRange.end, + ), + ), + ); + } + buffer.write(label); + offset += label.length; + } + _cachedAttributedValue = AttributedString( + buffer.toString(), + attributes: attributes, + ); + } + } + config + ..attributedValue = _cachedAttributedValue! + ..isObscured = obscureText + ..isMultiline = _isMultiline + ..textDirection = textDirection + ..isFocused = hasFocus + ..isTextField = true + ..isReadOnly = readOnly + // This is the default for customer that uses RenderEditable directly. + // The real value is typically set by EditableText. + ..inputType = ui.SemanticsInputType.text; + + if (hasFocus && selectionEnabled) { + config.onSetSelection = _handleSetSelection; + } + + if (hasFocus && !readOnly) { + config.onSetText = _handleSetText; + } + + if (selectionEnabled && (selection?.isValid ?? false)) { + config.textSelection = selection; + if (_textPainter.getOffsetBefore(selection!.extentOffset) != null) { + config + ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord + ..onMoveCursorBackwardByCharacter = + _handleMoveCursorBackwardByCharacter; + } + if (_textPainter.getOffsetAfter(selection!.extentOffset) != null) { + config + ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord + ..onMoveCursorForwardByCharacter = + _handleMoveCursorForwardByCharacter; + } + } + } + + void _handleSetText(String text) { + textSelectionDelegate.userUpdateTextEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + SelectionChangedCause.keyboard, + ); + } + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable children, + ) { + assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); + final List newChildren = []; + TextDirection currentDirection = textDirection; + Rect currentRect; + double ordinal = 0.0; + int start = 0; + int placeholderIndex = 0; + int childIndex = 0; + RenderBox? child = firstChild; + final LinkedHashMap newChildCache = + LinkedHashMap(); + _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); + for (final InlineSpanSemanticsInformation info + in _cachedCombinedSemanticsInfos!) { + final TextSelection selection = TextSelection( + baseOffset: start, + extentOffset: start + info.text.length, + ); + start += info.text.length; + + if (info.isPlaceholder) { + // A placeholder span may have 0 to multiple semantics nodes, we need + // to annotate all of the semantics nodes belong to this span. + while (children.length > childIndex && + children + .elementAt(childIndex) + .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + final SemanticsNode childNode = children.elementAt(childIndex); + final TextParentData parentData = + child!.parentData! as TextParentData; + assert(parentData.offset != null); + newChildren.add(childNode); + childIndex += 1; + } + child = childAfter(child!); + placeholderIndex += 1; + } else { + final TextDirection initialDirection = currentDirection; + final List rects = _textPainter.getBoxesForSelection( + selection, + ); + if (rects.isEmpty) { + continue; + } + Rect rect = rects.first.toRect(); + currentDirection = rects.first.direction; + for (final ui.TextBox textBox in rects.skip(1)) { + rect = rect.expandToInclude(textBox.toRect()); + currentDirection = textBox.direction; + } + // Any of the text boxes may have had infinite dimensions. + // We shouldn't pass infinite dimensions up to the bridges. + rect = Rect.fromLTWH( + math.max(0.0, rect.left), + math.max(0.0, rect.top), + math.min(rect.width, constraints.maxWidth), + math.min(rect.height, constraints.maxHeight), + ); + // Round the current rectangle to make this API testable and add some + // padding so that the accessibility rects do not overlap with the text. + currentRect = Rect.fromLTRB( + rect.left.floorToDouble() - 4.0, + rect.top.floorToDouble() - 4.0, + rect.right.ceilToDouble() + 4.0, + rect.bottom.ceilToDouble() + 4.0, + ); + final SemanticsConfiguration configuration = SemanticsConfiguration() + ..sortKey = OrdinalSortKey(ordinal++) + ..textDirection = initialDirection + ..attributedLabel = AttributedString( + info.semanticsLabel ?? info.text, + attributes: info.stringAttributes, + ); + switch (info.recognizer) { + case TapGestureRecognizer(onTap: final VoidCallback? handler): + case DoubleTapGestureRecognizer( + onDoubleTap: final VoidCallback? handler, + ): + if (handler != null) { + configuration.onTap = handler; + configuration.isLink = true; + } + case LongPressGestureRecognizer( + onLongPress: final GestureLongPressCallback? onLongPress, + ): + if (onLongPress != null) { + configuration.onLongPress = onLongPress; + } + case null: + break; + default: + assert(false, '${info.recognizer.runtimeType} is not supported.'); + } + if (node.parentPaintClipRect != null) { + final Rect paintRect = node.parentPaintClipRect!.intersect( + currentRect, + ); + configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; + } + late final SemanticsNode newChild; + if (_cachedChildNodes?.isNotEmpty ?? false) { + newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; + } else { + final UniqueKey key = UniqueKey(); + newChild = SemanticsNode( + key: key, + showOnScreen: _createShowOnScreenFor(key), + ); + } + newChild + ..updateWith(config: configuration) + ..rect = currentRect; + newChildCache[newChild.key!] = newChild; + newChildren.add(newChild); + } + } + _cachedChildNodes = newChildCache; + node.updateWith(config: config, childrenInInversePaintOrder: newChildren); + } + + VoidCallback? _createShowOnScreenFor(Key key) { + return () { + final SemanticsNode node = _cachedChildNodes![key]!; + showOnScreen(descendant: this, rect: node.rect); + }; + } + + // TODO(ianh): in theory, [selection] could become null between when + // we last called describeSemanticsConfiguration and when the + // callbacks are invoked, in which case the callbacks will crash... + + void _handleSetSelection(TextSelection selection) { + _setSelection(selection, SelectionChangedCause.keyboard); + } + + void _handleMoveCursorForwardByCharacter(bool extendSelection) { + assert(selection != null); + final int? extentOffset = _textPainter.getOffsetAfter( + selection!.extentOffset, + ); + if (extentOffset == null) { + return; + } + final int baseOffset = + !extendSelection ? extentOffset : selection!.baseOffset; + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), + SelectionChangedCause.keyboard, + ); + } + + void _handleMoveCursorBackwardByCharacter(bool extendSelection) { + assert(selection != null); + final int? extentOffset = _textPainter.getOffsetBefore( + selection!.extentOffset, + ); + if (extentOffset == null) { + return; + } + final int baseOffset = + !extendSelection ? extentOffset : selection!.baseOffset; + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), + SelectionChangedCause.keyboard, + ); + } + + void _handleMoveCursorForwardByWord(bool extendSelection) { + assert(selection != null); + final TextRange currentWord = _textPainter.getWordBoundary( + selection!.extent, + ); + final TextRange? nextWord = _getNextWord(currentWord.end); + if (nextWord == null) { + return; + } + final int baseOffset = + extendSelection ? selection!.baseOffset : nextWord.start; + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: nextWord.start), + SelectionChangedCause.keyboard, + ); + } + + void _handleMoveCursorBackwardByWord(bool extendSelection) { + assert(selection != null); + final TextRange currentWord = _textPainter.getWordBoundary( + selection!.extent, + ); + final TextRange? previousWord = _getPreviousWord(currentWord.start - 1); + if (previousWord == null) { + return; + } + final int baseOffset = + extendSelection ? selection!.baseOffset : previousWord.start; + _setSelection( + TextSelection(baseOffset: baseOffset, extentOffset: previousWord.start), + SelectionChangedCause.keyboard, + ); + } + + TextRange? _getNextWord(int offset) { + while (true) { + final TextRange range = _textPainter.getWordBoundary( + TextPosition(offset: offset), + ); + if (!range.isValid || range.isCollapsed) { + return null; + } + if (!_onlyWhitespace(range)) { + return range; + } + offset = range.end; + } + } + + TextRange? _getPreviousWord(int offset) { + while (offset >= 0) { + final TextRange range = _textPainter.getWordBoundary( + TextPosition(offset: offset), + ); + if (!range.isValid || range.isCollapsed) { + return null; + } + if (!_onlyWhitespace(range)) { + return range; + } + offset = range.start - 1; + } + return null; + } + + // Check if the given text range only contains white space or separator + // characters. + // + // Includes newline characters from ASCII and separators from the + // [unicode separator category](https://www.compart.com/en/unicode/category/Zs) + // TODO(zanderso): replace when we expose this ICU information. + bool _onlyWhitespace(TextRange range) { + for (int i = range.start; i < range.end; i++) { + final int codeUnit = text!.codeUnitAt(i)!; + if (!TextLayoutMetrics.isWhitespace(codeUnit)) { + return false; + } + } + return true; + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _foregroundRenderObject?.attach(owner); + _backgroundRenderObject?.attach(owner); + + _tap = TapGestureRecognizer(debugOwner: this) + ..onTapDown = _handleTapDown + ..onTap = _handleTap; + _longPress = LongPressGestureRecognizer(debugOwner: this) + ..onLongPress = _handleLongPress; + _offset.addListener(markNeedsPaint); + _showHideCursor(); + _showCursor.addListener(_showHideCursor); + } + + @override + void detach() { + _tap.dispose(); + _longPress.dispose(); + _offset.removeListener(markNeedsPaint); + _showCursor.removeListener(_showHideCursor); + super.detach(); + _foregroundRenderObject?.detach(); + _backgroundRenderObject?.detach(); + } + + @override + void redepthChildren() { + final RenderObject? foregroundChild = _foregroundRenderObject; + final RenderObject? backgroundChild = _backgroundRenderObject; + if (foregroundChild != null) { + redepthChild(foregroundChild); + } + if (backgroundChild != null) { + redepthChild(backgroundChild); + } + super.redepthChildren(); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + final RenderObject? foregroundChild = _foregroundRenderObject; + final RenderObject? backgroundChild = _backgroundRenderObject; + if (foregroundChild != null) { + visitor(foregroundChild); + } + if (backgroundChild != null) { + visitor(backgroundChild); + } + super.visitChildren(visitor); + } + + bool get _isMultiline => maxLines != 1; + + Axis get _viewportAxis => _isMultiline ? Axis.vertical : Axis.horizontal; + + Offset get _paintOffset => switch (_viewportAxis) { + Axis.horizontal => Offset(-offset.pixels, 0.0), + Axis.vertical => Offset(0.0, -offset.pixels), + }; + + double get _viewportExtent { + assert(hasSize); + return switch (_viewportAxis) { + Axis.horizontal => size.width, + Axis.vertical => size.height, + }; + } + + double _getMaxScrollExtent(Size contentSize) { + assert(hasSize); + return switch (_viewportAxis) { + Axis.horizontal => math.max(0.0, contentSize.width - size.width), + Axis.vertical => math.max(0.0, contentSize.height - size.height), + }; + } + + // We need to check the paint offset here because during animation, the start of + // the text may position outside the visible region even when the text fits. + bool get _hasVisualOverflow => + _maxScrollExtent > 0 || _paintOffset != Offset.zero; + + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). + /// + /// See also: + /// + /// * [getLocalRectForCaret], which is the equivalent but for + /// a [TextPosition] rather than a [TextSelection]. + List getEndpointsForSelection(TextSelection selection) { + _computeTextMetricsIfNeeded(); + + final Offset paintOffset = _paintOffset; + + final List boxes = selection.isCollapsed + ? [] + : _textPainter.getBoxesForSelection( + selection, + boxHeightStyle: selectionHeightStyle, + boxWidthStyle: selectionWidthStyle, + ); + if (boxes.isEmpty) { + // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. + final Offset caretOffset = _textPainter.getOffsetForCaret( + selection.extent, + _caretPrototype, + ); + final Offset start = + Offset(0.0, preferredLineHeight) + caretOffset + paintOffset; + return [TextSelectionPoint(start, null)]; + } else { + final Offset start = Offset( + clampDouble(boxes.first.start, 0, _textPainter.size.width), + boxes.first.bottom, + ) + + paintOffset; + final Offset end = Offset( + clampDouble(boxes.last.end, 0, _textPainter.size.width), + boxes.last.bottom, + ) + + paintOffset; + return [ + TextSelectionPoint(start, boxes.first.direction), + TextSelectionPoint(end, boxes.last.direction), + ]; + } + } + + /// Returns the smallest [Rect], in the local coordinate system, that covers + /// the text within the [TextRange] specified. + /// + /// This method is used to calculate the approximate position of the IME bar + /// on iOS. + /// + /// Returns null if [TextRange.isValid] is false for the given `range`, or the + /// given `range` is collapsed. + Rect? getRectForComposingRange(TextRange range) { + if (!range.isValid || range.isCollapsed) { + return null; + } + _computeTextMetricsIfNeeded(); + + final List boxes = _textPainter.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + boxHeightStyle: selectionHeightStyle, + boxWidthStyle: selectionWidthStyle, + ); + + return boxes + .fold( + null, + (Rect? accum, TextBox incoming) => + accum?.expandToInclude(incoming.toRect()) ?? incoming.toRect(), + ) + ?.shift(_paintOffset); + } + + /// Returns the position in the text for the given global coordinate. + /// + /// See also: + /// + /// * [getLocalRectForCaret], which is the reverse operation, taking + /// a [TextPosition] and returning a [Rect]. + /// * [TextPainter.getPositionForOffset], which is the equivalent method + /// for a [TextPainter] object. + TextPosition getPositionForPoint(Offset globalPosition) { + _computeTextMetricsIfNeeded(); + return _textPainter.getPositionForOffset( + globalToLocal(globalPosition) - _paintOffset, + ); + } + + /// Returns the [Rect] in local coordinates for the caret at the given text + /// position. + /// + /// See also: + /// + /// * [getPositionForPoint], which is the reverse operation, taking + /// an [Offset] in global coordinates and returning a [TextPosition]. + /// * [getEndpointsForSelection], which is the equivalent but for + /// a selection rather than a particular text position. + /// * [TextPainter.getOffsetForCaret], the equivalent method for a + /// [TextPainter] object. + Rect getLocalRectForCaret(TextPosition caretPosition) { + _computeTextMetricsIfNeeded(); + final Rect caretPrototype = _caretPrototype; + final Offset caretOffset = _textPainter.getOffsetForCaret( + caretPosition, + caretPrototype, + ); + Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset); + final double scrollableWidth = math.max( + _textPainter.width + _caretMargin, + size.width, + ); + + final double caretX = clampDouble( + caretRect.left, + 0, + math.max(scrollableWidth - _caretMargin, 0), + ); + caretRect = Offset(caretX, caretRect.top) & caretRect.size; + + final double fullHeight = _textPainter.getFullHeightForCaret( + caretPosition, + caretPrototype, + ); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // Center the caret vertically along the text. + final double heightDiff = fullHeight - caretRect.height; + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top + heightDiff / 2, + caretRect.width, + caretRect.height, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Override the height to take the full height of the glyph at the TextPosition + // when not on iOS. iOS has special handling that creates a taller caret. + // TODO(garyq): see https://github.com/flutter/flutter/issues/120836. + final double caretHeight = cursorHeight; + // Center the caret vertically along the text. + final double heightDiff = fullHeight - caretHeight; + caretRect = Rect.fromLTWH( + caretRect.left, + caretRect.top - _kCaretHeightOffset + heightDiff / 2, + caretRect.width, + caretHeight, + ); + } + + caretRect = caretRect.shift(_paintOffset); + return caretRect.shift(_snapToPhysicalPixel(caretRect.topLeft)); + } + + @override + double computeMinIntrinsicWidth(double height) { + final List placeholderDimensions = + layoutInlineChildren( + double.infinity, + (RenderBox child, BoxConstraints constraints) => + Size(child.getMinIntrinsicWidth(double.infinity), 0.0), + ChildLayoutHelper.getDryBaseline, + ); + final (double minWidth, double maxWidth) = _adjustConstraints(); + return (_textIntrinsics + ..setPlaceholderDimensions(placeholderDimensions) + ..layout(minWidth: minWidth, maxWidth: maxWidth)) + .minIntrinsicWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final List placeholderDimensions = + layoutInlineChildren( + double.infinity, + // Height and baseline is irrelevant as all text will be laid + // out in a single line. Therefore, using 0.0 as a dummy for the height. + (RenderBox child, BoxConstraints constraints) => + Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), + ChildLayoutHelper.getDryBaseline, + ); + final (double minWidth, double maxWidth) = _adjustConstraints(); + return (_textIntrinsics + ..setPlaceholderDimensions(placeholderDimensions) + ..layout(minWidth: minWidth, maxWidth: maxWidth)) + .maxIntrinsicWidth + + _caretMargin; + } + + /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. + /// This does not require the layout to be updated. + double get preferredLineHeight => _textPainter.preferredLineHeight; + + int? _cachedLineBreakCount; + int _countHardLineBreaks(String text) { + final int? cachedValue = _cachedLineBreakCount; + if (cachedValue != null) { + return cachedValue; + } + int count = 0; + for (int index = 0; index < text.length; index += 1) { + switch (text.codeUnitAt(index)) { + case 0x000A: // LF + case 0x0085: // NEL + case 0x000B: // VT + case 0x000C: // FF, treating it as a regular line separator + case 0x2028: // LS + case 0x2029: // PS + count += 1; + } + } + return _cachedLineBreakCount = count; + } + + double _preferredHeight(double width) { + final int? maxLines = this.maxLines; + final int? minLines = this.minLines ?? maxLines; + final double minHeight = preferredLineHeight * (minLines ?? 0); + assert(maxLines != 1 || _textIntrinsics.maxLines == 1); + + if (maxLines == null) { + final double estimatedHeight; + if (width == double.infinity) { + estimatedHeight = + preferredLineHeight * (_countHardLineBreaks(plainText) + 1); + } else { + final (double minWidth, double maxWidth) = _adjustConstraints( + maxWidth: width, + ); + estimatedHeight = (_textIntrinsics + ..layout(minWidth: minWidth, maxWidth: maxWidth)) + .height; + } + return math.max(estimatedHeight, minHeight); + } + + // Special case maxLines == 1 since it forces the scrollable direction + // to be horizontal. Report the real height to prevent the text from being + // clipped. + if (maxLines == 1) { + // The _layoutText call lays out the paragraph using infinite width when + // maxLines == 1. Also _textPainter.maxLines will be set to 1 so should + // there be any line breaks only the first line is shown. + final (double minWidth, double maxWidth) = _adjustConstraints( + maxWidth: width, + ); + return (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth)) + .height; + } + if (minLines == maxLines) { + return minHeight; + } + final double maxHeight = preferredLineHeight * maxLines; + final (double minWidth, double maxWidth) = _adjustConstraints( + maxWidth: width, + ); + return clampDouble( + (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth)).height, + minHeight, + maxHeight, + ); + } + + @override + double computeMinIntrinsicHeight(double width) => + getMaxIntrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) { + _textIntrinsics.setPlaceholderDimensions( + layoutInlineChildren( + width, + ChildLayoutHelper.dryLayoutChild, + ChildLayoutHelper.getDryBaseline, + ), + ); + return _preferredHeight(width); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + _computeTextMetricsIfNeeded(); + return _textPainter.computeDistanceToActualBaseline(baseline); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + @protected + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final Offset effectivePosition = position - _paintOffset; + final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset( + effectivePosition, + ); + // The hit-test can't fall through the horizontal gaps between visually + // adjacent characters on the same line, even with a large letter-spacing or + // text justification, as graphemeClusterLayoutBounds.width is the advance + // width to the next character, so there's no gap between their + // graphemeClusterLayoutBounds rects. + final InlineSpan? spanHit = glyph != null && + glyph.graphemeClusterLayoutBounds.contains(effectivePosition) + ? _textPainter.text!.getSpanForPosition( + TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start), + ) + : null; + switch (spanHit) { + case final HitTestTarget span: + result.add(HitTestEntry(span)); + return true; + case _: + return hitTestInlineChildren(result, effectivePosition); + } + } + + late TapGestureRecognizer _tap; + late LongPressGestureRecognizer _longPress; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent) { + assert(!debugNeedsLayout); + + if (!ignorePointer) { + // Propagates the pointer event to selection handlers. + _tap.addPointer(event); + _longPress.addPointer(event); + } + } + } + + Offset? _lastTapDownPosition; + Offset? _lastSecondaryTapDownPosition; + + /// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition} + /// The position of the most recent secondary tap down event on this text + /// input. + /// {@endtemplate} + Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition; + + /// Tracks the position of a secondary tap event. + /// + /// Should be called before attempting to change the selection based on the + /// position of a secondary tap. + void handleSecondaryTapDown(TapDownDetails details) { + _lastTapDownPosition = details.globalPosition; + _lastSecondaryTapDownPosition = details.globalPosition; + } + + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to tap + /// down events by calling this method. + void handleTapDown(TapDownDetails details) { + _lastTapDownPosition = details.globalPosition; + } + + void _handleTapDown(TapDownDetails details) { + assert(!ignorePointer); + handleTapDown(details); + } + + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [TapGestureRecognizer.onTap] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to tap + /// events by calling this method. + void handleTap() { + selectPosition(cause: SelectionChangedCause.tap); + } + + void _handleTap() { + assert(!ignorePointer); + handleTap(); + } + + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to double + /// tap events by calling this method. + void handleDoubleTap() { + selectWord(cause: SelectionChangedCause.doubleTap); + } + + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to long + /// press events by calling this method. + void handleLongPress() { + selectWord(cause: SelectionChangedCause.longPress); + } + + void _handleLongPress() { + assert(!ignorePointer); + handleLongPress(); + } + + /// Move selection to the location of the last tap down. + /// + /// {@template flutter.rendering.RenderEditable.selectPosition} + /// This method is mainly used to translate user inputs in global positions + /// into a [TextSelection]. When used in conjunction with a [EditableText], + /// the selection change is fed back into [TextEditingController.selection]. + /// + /// If you have a [TextEditingController], it's generally easier to + /// programmatically manipulate its `value` or `selection` directly. + /// {@endtemplate} + void selectPosition({required SelectionChangedCause cause}) { + selectPositionAt(from: _lastTapDownPosition!, cause: cause); + } + + /// Select text between the global positions [from] and [to]. + /// + /// [from] corresponds to the [TextSelection.baseOffset], and [to] corresponds + /// to the [TextSelection.extentOffset]. + void selectPositionAt({ + required Offset from, + Offset? to, + required SelectionChangedCause cause, + }) { + final localFrom = globalToLocal(from); + _computeTextMetricsIfNeeded(); + final TextPosition fromPosition = _textPainter.getPositionForOffset( + localFrom - _paintOffset, + ); + + final TextPosition? toPosition = to == null + ? null + : _textPainter.getPositionForOffset( + globalToLocal(to) - _paintOffset, + ); + + int baseOffset = fromPosition.offset; + int extentOffset = toPosition?.offset ?? fromPosition.offset; + + // bggRGjQaUbCoE tap + if (toPosition == null) { + baseOffset = controller.tapOffset( + baseOffset, + textPainter: _textPainter, + localPos: localFrom, + lastTapDownPosition: from, + ); + extentOffset = baseOffset; + } + + final TextSelection newSelection = TextSelection( + baseOffset: baseOffset, + extentOffset: extentOffset, + affinity: fromPosition.affinity, + ); + + _setSelection(newSelection, cause); + } + + /// {@macro flutter.painting.TextPainter.wordBoundaries} + WordBoundary get wordBoundaries => _textPainter.wordBoundaries; + + /// Select a word around the location of the last tap down. + /// + /// {@macro flutter.rendering.RenderEditable.selectPosition} + void selectWord({required SelectionChangedCause cause}) { + selectWordsInRange(from: _lastTapDownPosition!, cause: cause); + } + + /// Selects the set words of a paragraph that intersect a given range of global positions. + /// + /// The set of words selected are not strictly bounded by the range of global positions. + /// + /// The first and last endpoints of the selection will always be at the + /// beginning and end of a word respectively. + /// + /// {@macro flutter.rendering.RenderEditable.selectPosition} + + void selectWordsInRange({ + required Offset from, + Offset? to, + required SelectionChangedCause cause, + }) { + _computeTextMetricsIfNeeded(); + final TextPosition fromPosition = _textPainter.getPositionForOffset( + globalToLocal(from) - _paintOffset, + ); + final TextSelection fromWord = getWordAtOffset(fromPosition); + final TextPosition toPosition = to == null + ? fromPosition + : _textPainter.getPositionForOffset( + globalToLocal(to) - _paintOffset, + ); + final TextSelection toWord = + toPosition == fromPosition ? fromWord : getWordAtOffset(toPosition); + final bool isFromWordBeforeToWord = fromWord.start < toWord.end; + + // bggRGjQaUbCoE longpress + var startOffset = + isFromWordBeforeToWord ? fromWord.baseOffset : toWord.baseOffset; + var endOffset = + isFromWordBeforeToWord ? toWord.extentOffset : fromWord.extentOffset; + final newOffset = controller.longPressOffset(startOffset, endOffset); + startOffset = newOffset.startOffset; + endOffset = newOffset.endOffset; + + _setSelection( + TextSelection( + baseOffset: isFromWordBeforeToWord ? startOffset : endOffset, + extentOffset: isFromWordBeforeToWord ? endOffset : startOffset, + affinity: fromWord.affinity, + ), + cause, + ); + } + + /// Move the selection to the beginning or end of a word. + /// + /// {@macro flutter.rendering.RenderEditable.selectPosition} + void selectWordEdge({required SelectionChangedCause cause}) { + _computeTextMetricsIfNeeded(); + assert(_lastTapDownPosition != null); + final localPos = globalToLocal(_lastTapDownPosition!); + TextPosition position = _textPainter.getPositionForOffset( + localPos - _paintOffset, + ); + + // bggRGjQaUbCoE ios tap + final newOffset = controller.tapOffset( + position.offset, + textPainter: _textPainter, + localPos: localPos, + lastTapDownPosition: _lastTapDownPosition!, + ); + position = TextPosition(offset: newOffset); + + final TextRange word = _textPainter.getWordBoundary(position); + late TextSelection newSelection; + if (position.offset <= word.start) { + newSelection = TextSelection.collapsed(offset: word.start); + } else { + newSelection = TextSelection.collapsed( + offset: word.end, + affinity: TextAffinity.upstream, + ); + } + _setSelection(newSelection, cause); + } + + /// Returns a [TextSelection] that encompasses the word at the given + /// [TextPosition]. + @visibleForTesting + TextSelection getWordAtOffset(TextPosition position) { + // When long-pressing past the end of the text, we want a collapsed cursor. + if (position.offset >= plainText.length) { + return TextSelection.fromPosition( + TextPosition(offset: plainText.length, affinity: TextAffinity.upstream), + ); + } + // If text is obscured, the entire sentence should be treated as one word. + if (obscureText) { + return TextSelection(baseOffset: 0, extentOffset: plainText.length); + } + final TextRange word = _textPainter.getWordBoundary(position); + final int effectiveOffset; + switch (position.affinity) { + case TextAffinity.upstream: + // upstream affinity is effectively -1 in text position. + effectiveOffset = position.offset - 1; + case TextAffinity.downstream: + effectiveOffset = position.offset; + } + assert(effectiveOffset >= 0); + + // On iOS, select the previous word if there is a previous word, or select + // to the end of the next word if there is a next word. Select nothing if + // there is neither a previous word nor a next word. + // + // If the platform is Android and the text is read only, try to select the + // previous word if there is one; otherwise, select the single whitespace at + // the position. + if (effectiveOffset > 0 && + TextLayoutMetrics.isWhitespace(plainText.codeUnitAt(effectiveOffset))) { + final TextRange? previousWord = _getPreviousWord(word.start); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + if (previousWord == null) { + final TextRange? nextWord = _getNextWord(word.start); + if (nextWord == null) { + return TextSelection.collapsed(offset: position.offset); + } + return TextSelection( + baseOffset: position.offset, + extentOffset: nextWord.end, + ); + } + return TextSelection( + baseOffset: previousWord.start, + extentOffset: position.offset, + ); + case TargetPlatform.android: + if (readOnly) { + if (previousWord == null) { + return TextSelection( + baseOffset: position.offset, + extentOffset: position.offset + 1, + ); + } + return TextSelection( + baseOffset: previousWord.start, + extentOffset: position.offset, + ); + } + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + } + + return TextSelection(baseOffset: word.start, extentOffset: word.end); + } + + // Placeholder dimensions representing the sizes of child inline widgets. + // + // These need to be cached because the text painter's placeholder dimensions + // will be overwritten during intrinsic width/height calculations and must be + // restored to the original values before final layout and painting. + List? _placeholderDimensions; + + (double minWidth, double maxWidth) _adjustConstraints({ + double minWidth = 0.0, + double maxWidth = double.infinity, + }) { + final double availableMaxWidth = math.max(0.0, maxWidth - _caretMargin); + final double availableMinWidth = math.min(minWidth, availableMaxWidth); + return ( + forceLine ? availableMaxWidth : availableMinWidth, + _isMultiline ? availableMaxWidth : double.infinity, + ); + } + + // Computes the text metrics if `_textPainter`'s layout information was marked + // as dirty. + // + // This method must be called in `RenderEditable`'s public methods that expose + // `_textPainter`'s metrics. For instance, `systemFontsDidChange` sets + // _textPainter._paragraph to null, so accessing _textPainter's metrics + // immediately after `systemFontsDidChange` without first calling this method + // may crash. + // + // This method is also called in various paint methods (`RenderEditable.paint` + // as well as its foreground/background painters' `paint`). It's needed + // because invisible render objects kept in the tree by `KeepAlive` may not + // get a chance to do layout but can still paint. + // See https://github.com/flutter/flutter/issues/84896. + // + // This method only re-computes layout if the underlying `_textPainter`'s + // layout cache is invalidated (by calling `TextPainter.markNeedsLayout`), or + // the constraints used to layout the `_textPainter` is different. See + // `TextPainter.layout`. + void _computeTextMetricsIfNeeded() { + final (double minWidth, double maxWidth) = _adjustConstraints( + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + ); + _textPainter.layout(minWidth: minWidth, maxWidth: maxWidth); + } + + late Rect _caretPrototype; + + // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/120836 + // + /// On iOS, the cursor is taller than the cursor on Android. The height + /// of the cursor for iOS is approximate and obtained through an eyeball + /// comparison. + void _computeCaretPrototype() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _caretPrototype = Rect.fromLTWH( + 0.0, + 0.0, + cursorWidth, + cursorHeight + 2, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _caretPrototype = Rect.fromLTWH( + 0.0, + _kCaretHeightOffset, + cursorWidth, + cursorHeight - 2.0 * _kCaretHeightOffset, + ); + } + } + + // Computes the offset to apply to the given [sourceOffset] so it perfectly + // snaps to physical pixels. + Offset _snapToPhysicalPixel(Offset sourceOffset) { + final Offset globalOffset = localToGlobal(sourceOffset); + final double pixelMultiple = 1.0 / _devicePixelRatio; + return Offset( + globalOffset.dx.isFinite + ? (globalOffset.dx / pixelMultiple).round() * pixelMultiple - + globalOffset.dx + : 0, + globalOffset.dy.isFinite + ? (globalOffset.dy / pixelMultiple).round() * pixelMultiple - + globalOffset.dy + : 0, + ); + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final (double minWidth, double maxWidth) = _adjustConstraints( + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + ); + _textIntrinsics + ..setPlaceholderDimensions( + layoutInlineChildren( + constraints.maxWidth, + ChildLayoutHelper.dryLayoutChild, + ChildLayoutHelper.getDryBaseline, + ), + ) + ..layout(minWidth: minWidth, maxWidth: maxWidth); + final double width = forceLine + ? constraints.maxWidth + : constraints.constrainWidth( + _textIntrinsics.size.width + _caretMargin, + ); + return Size( + width, + constraints.constrainHeight(_preferredHeight(constraints.maxWidth)), + ); + } + + @override + double computeDryBaseline( + covariant BoxConstraints constraints, + TextBaseline baseline, + ) { + final (double minWidth, double maxWidth) = _adjustConstraints( + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + ); + _textIntrinsics + ..setPlaceholderDimensions( + layoutInlineChildren( + constraints.maxWidth, + ChildLayoutHelper.dryLayoutChild, + ChildLayoutHelper.getDryBaseline, + ), + ) + ..layout(minWidth: minWidth, maxWidth: maxWidth); + return _textIntrinsics.computeDistanceToActualBaseline(baseline); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + _placeholderDimensions = layoutInlineChildren( + constraints.maxWidth, + ChildLayoutHelper.layoutChild, + ChildLayoutHelper.getBaseline, + ); + final (double minWidth, double maxWidth) = _adjustConstraints( + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + ); + _textPainter + ..setPlaceholderDimensions(_placeholderDimensions) + ..layout(minWidth: minWidth, maxWidth: maxWidth); + positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); + _computeCaretPrototype(); + + final double width = forceLine + ? constraints.maxWidth + : constraints.constrainWidth(_textPainter.width + _caretMargin); + assert(maxLines != 1 || _textPainter.maxLines == 1); + final double preferredHeight = switch (maxLines) { + null => math.max( + _textPainter.height, + preferredLineHeight * (minLines ?? 0), + ), + 1 => _textPainter.height, + final int maxLines => clampDouble( + _textPainter.height, + preferredLineHeight * (minLines ?? maxLines), + preferredLineHeight * maxLines, + ), + }; + + size = Size(width, constraints.constrainHeight(preferredHeight)); + final Size contentSize = Size( + _textPainter.width + _caretMargin, + _textPainter.height, + ); + + final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize); + + _foregroundRenderObject?.layout(painterConstraints); + _backgroundRenderObject?.layout(painterConstraints); + + _maxScrollExtent = _getMaxScrollExtent(contentSize); + offset.applyViewportDimension(_viewportExtent); + offset.applyContentDimensions(0.0, _maxScrollExtent); + } + + // The relative origin in relation to the distance the user has theoretically + // dragged the floating cursor offscreen. This value is used to account for the + // difference in the rendering position and the raw offset value. + Offset _relativeOrigin = Offset.zero; + Offset? _previousOffset; + bool _shouldResetOrigin = true; + bool _resetOriginOnLeft = false; + bool _resetOriginOnRight = false; + bool _resetOriginOnTop = false; + bool _resetOriginOnBottom = false; + double? _resetFloatingCursorAnimationValue; + + static Offset _calculateAdjustedCursorOffset( + Offset offset, + Rect boundingRects, + ) { + final double adjustedX = clampDouble( + offset.dx, + boundingRects.left, + boundingRects.right, + ); + final double adjustedY = clampDouble( + offset.dy, + boundingRects.top, + boundingRects.bottom, + ); + return Offset(adjustedX, adjustedY); + } + + /// Returns the position within the text field closest to the raw cursor offset. + /// + /// See also: + /// + /// * [FloatingCursorDragState], which explains the floating cursor feature + /// in detail. + Offset calculateBoundedFloatingCursorOffset( + Offset rawCursorOffset, { + bool? shouldResetOrigin, + }) { + Offset deltaPosition = Offset.zero; + final double topBound = -floatingCursorAddedMargin.top; + final double bottomBound = math.min(size.height, _textPainter.height) - + preferredLineHeight + + floatingCursorAddedMargin.bottom; + final double leftBound = -floatingCursorAddedMargin.left; + final double rightBound = math.min(size.width, _textPainter.width) + + floatingCursorAddedMargin.right; + final Rect boundingRects = Rect.fromLTRB( + leftBound, + topBound, + rightBound, + bottomBound, + ); + + if (shouldResetOrigin != null) { + _shouldResetOrigin = shouldResetOrigin; + } + + if (!_shouldResetOrigin) { + return _calculateAdjustedCursorOffset(rawCursorOffset, boundingRects); + } + + if (_previousOffset != null) { + deltaPosition = rawCursorOffset - _previousOffset!; + } + + // If the raw cursor offset has gone off an edge, we want to reset the relative + // origin of the dragging when the user drags back into the field. + if (_resetOriginOnLeft && deltaPosition.dx > 0) { + _relativeOrigin = Offset( + rawCursorOffset.dx - boundingRects.left, + _relativeOrigin.dy, + ); + _resetOriginOnLeft = false; + } else if (_resetOriginOnRight && deltaPosition.dx < 0) { + _relativeOrigin = Offset( + rawCursorOffset.dx - boundingRects.right, + _relativeOrigin.dy, + ); + _resetOriginOnRight = false; + } + if (_resetOriginOnTop && deltaPosition.dy > 0) { + _relativeOrigin = Offset( + _relativeOrigin.dx, + rawCursorOffset.dy - boundingRects.top, + ); + _resetOriginOnTop = false; + } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { + _relativeOrigin = Offset( + _relativeOrigin.dx, + rawCursorOffset.dy - boundingRects.bottom, + ); + _resetOriginOnBottom = false; + } + + final double currentX = rawCursorOffset.dx - _relativeOrigin.dx; + final double currentY = rawCursorOffset.dy - _relativeOrigin.dy; + final Offset adjustedOffset = _calculateAdjustedCursorOffset( + Offset(currentX, currentY), + boundingRects, + ); + + if (currentX < boundingRects.left && deltaPosition.dx < 0) { + _resetOriginOnLeft = true; + } else if (currentX > boundingRects.right && deltaPosition.dx > 0) { + _resetOriginOnRight = true; + } + if (currentY < boundingRects.top && deltaPosition.dy < 0) { + _resetOriginOnTop = true; + } else if (currentY > boundingRects.bottom && deltaPosition.dy > 0) { + _resetOriginOnBottom = true; + } + + _previousOffset = rawCursorOffset; + + return adjustedOffset; + } + + /// Sets the screen position of the floating cursor and the text position + /// closest to the cursor. + /// + /// See also: + /// + /// * [FloatingCursorDragState], which explains the floating cursor feature + /// in detail. + void setFloatingCursor( + FloatingCursorDragState state, + Offset boundedOffset, + TextPosition lastTextPosition, { + double? resetLerpValue, + }) { + if (state == FloatingCursorDragState.End) { + _relativeOrigin = Offset.zero; + _previousOffset = null; + _shouldResetOrigin = true; + _resetOriginOnBottom = false; + _resetOriginOnTop = false; + _resetOriginOnRight = false; + _resetOriginOnBottom = false; + } + _floatingCursorOn = state != FloatingCursorDragState.End; + _resetFloatingCursorAnimationValue = resetLerpValue; + if (_floatingCursorOn) { + _floatingCursorTextPosition = lastTextPosition; + final double? animationValue = _resetFloatingCursorAnimationValue; + final EdgeInsets sizeAdjustment = animationValue != null + ? EdgeInsets.lerp( + _kFloatingCursorSizeIncrease, + EdgeInsets.zero, + animationValue, + )! + : _kFloatingCursorSizeIncrease; + _caretPainter.floatingCursorRect = + sizeAdjustment.inflateRect(_caretPrototype).shift(boundedOffset); + } else { + _caretPainter.floatingCursorRect = null; + } + _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null; + } + + MapEntry _lineNumberFor( + TextPosition startPosition, + List metrics, + ) { + // TODO(LongCatIsLooong): include line boundaries information in + // ui.LineMetrics, then we can get rid of this. + final Offset offset = _textPainter.getOffsetForCaret( + startPosition, + Rect.zero, + ); + for (final ui.LineMetrics lineMetrics in metrics) { + if (lineMetrics.baseline > offset.dy) { + return MapEntry( + lineMetrics.lineNumber, + Offset(offset.dx, lineMetrics.baseline), + ); + } + } + assert( + startPosition.offset == 0, + 'unable to find the line for $startPosition', + ); + return MapEntry( + math.max(0, metrics.length - 1), + Offset( + offset.dx, + metrics.isNotEmpty ? metrics.last.baseline + metrics.last.descent : 0.0, + ), + ); + } + + /// Starts a [VerticalCaretMovementRun] at the given location in the text, for + /// handling consecutive vertical caret movements. + /// + /// This can be used to handle consecutive upward/downward arrow key movements + /// in an input field. + /// + /// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement} + /// + /// The [VerticalCaretMovementRun.isValid] property indicates whether the text + /// layout has changed and the vertical caret run is invalidated. + /// + /// The caller should typically discard a [VerticalCaretMovementRun] when + /// its [VerticalCaretMovementRun.isValid] becomes false, or on other + /// occasions where the vertical caret run should be interrupted. + VerticalCaretMovementRun startVerticalCaretMovement( + TextPosition startPosition, + ) { + final List metrics = _textPainter.computeLineMetrics(); + final MapEntry currentLine = _lineNumberFor( + startPosition, + metrics, + ); + return VerticalCaretMovementRun._( + this, + metrics, + startPosition, + currentLine.key, + currentLine.value, + ); + } + + void _paintContents(PaintingContext context, Offset offset) { + final Offset effectiveOffset = offset + _paintOffset; + + if (selection != null && !_floatingCursorOn) { + _updateSelectionExtentsVisibility(effectiveOffset); + } + + final RenderBox? foregroundChild = _foregroundRenderObject; + final RenderBox? backgroundChild = _backgroundRenderObject; + + // The painters paint in the viewport's coordinate space, since the + // textPainter's coordinate space is not known to high level widgets. + if (backgroundChild != null) { + context.paintChild(backgroundChild, offset); + } + + _textPainter.paint(context.canvas, effectiveOffset); + paintInlineChildren(context, effectiveOffset); + + if (foregroundChild != null) { + context.paintChild(foregroundChild, offset); + } + } + + final LayerHandle _leaderLayerHandler = + LayerHandle(); + + void _paintHandleLayers( + PaintingContext context, + List endpoints, + Offset offset, + ) { + Offset startPoint = endpoints[0].point; + startPoint = Offset( + clampDouble(startPoint.dx, 0.0, size.width), + clampDouble(startPoint.dy, 0.0, size.height), + ); + _leaderLayerHandler.layer = LeaderLayer( + link: startHandleLayerLink, + offset: startPoint + offset, + ); + context.pushLayer(_leaderLayerHandler.layer!, super.paint, Offset.zero); + if (endpoints.length == 2) { + Offset endPoint = endpoints[1].point; + endPoint = Offset( + clampDouble(endPoint.dx, 0.0, size.width), + clampDouble(endPoint.dy, 0.0, size.height), + ); + context.pushLayer( + LeaderLayer(link: endHandleLayerLink, offset: endPoint + offset), + super.paint, + Offset.zero, + ); + } else if (selection!.isCollapsed) { + context.pushLayer( + LeaderLayer(link: endHandleLayerLink, offset: startPoint + offset), + super.paint, + Offset.zero, + ); + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + if (child == _foregroundRenderObject || child == _backgroundRenderObject) { + return; + } + defaultApplyPaintTransform(child, transform); + } + + @override + void paint(PaintingContext context, Offset offset) { + _computeTextMetricsIfNeeded(); + if (_hasVisualOverflow && clipBehavior != Clip.none) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + _paintContents, + clipBehavior: clipBehavior, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + _paintContents(context, offset); + } + final TextSelection? selection = this.selection; + if (selection != null && selection.isValid) { + _paintHandleLayers(context, getEndpointsForSelection(selection), offset); + } + } + + final LayerHandle _clipRectLayer = + LayerHandle(); + + @override + Rect? describeApproximatePaintClip(RenderObject child) { + switch (clipBehavior) { + case Clip.none: + return null; + case Clip.hardEdge: + case Clip.antiAlias: + case Clip.antiAliasWithSaveLayer: + return _hasVisualOverflow ? Offset.zero & size : null; + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('cursorColor', cursorColor)); + properties.add( + DiagnosticsProperty>('showCursor', showCursor), + ); + properties.add(IntProperty('maxLines', maxLines)); + properties.add(IntProperty('minLines', minLines)); + properties.add( + DiagnosticsProperty('expands', expands, defaultValue: false), + ); + properties.add(ColorProperty('selectionColor', selectionColor)); + properties.add( + DiagnosticsProperty( + 'textScaler', + textScaler, + defaultValue: TextScaler.noScaling, + ), + ); + properties.add( + DiagnosticsProperty('locale', locale, defaultValue: null), + ); + properties.add(DiagnosticsProperty('selection', selection)); + properties.add(DiagnosticsProperty('offset', offset)); + } + + @override + List debugDescribeChildren() { + return [ + if (text != null) + text!.toDiagnosticsNode( + name: 'text', + style: DiagnosticsTreeStyle.transition, + ), + ]; + } +} + +class _RenderEditableCustomPaint extends RenderBox { + _RenderEditableCustomPaint({RenderEditablePainter? painter}) + : _painter = painter, + super(); + + @override + RenderEditable? get parent => super.parent as RenderEditable?; + + @override + bool get isRepaintBoundary => true; + + @override + bool get sizedByParent => true; + + RenderEditablePainter? get painter => _painter; + RenderEditablePainter? _painter; + set painter(RenderEditablePainter? newValue) { + if (newValue == painter) { + return; + } + + final RenderEditablePainter? oldPainter = painter; + _painter = newValue; + + if (newValue?.shouldRepaint(oldPainter) ?? true) { + markNeedsPaint(); + } + + if (attached) { + oldPainter?.removeListener(markNeedsPaint); + newValue?.addListener(markNeedsPaint); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderEditable? parent = this.parent; + assert(parent != null); + final RenderEditablePainter? painter = this.painter; + if (painter != null && parent != null) { + parent._computeTextMetricsIfNeeded(); + painter.paint(context.canvas, size, parent); + } + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _painter?.addListener(markNeedsPaint); + } + + @override + void detach() { + _painter?.removeListener(markNeedsPaint); + super.detach(); + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) => + constraints.biggest; +} + +/// An interface that paints within a [RenderEditable]'s bounds, above or +/// beneath its text content. +/// +/// This painter is typically used for painting auxiliary content that depends +/// on text layout metrics (for instance, for painting carets and text highlight +/// blocks). It can paint independently from its [RenderEditable], allowing it +/// to repaint without triggering a repaint on the entire [RenderEditable] stack +/// when only auxiliary content changes (e.g. a blinking cursor) are present. It +/// will be scheduled to repaint when: +/// +/// * It's assigned to a new [RenderEditable] (replacing a prior +/// [RenderEditablePainter]) and the [shouldRepaint] method returns true. +/// * Any of the [RenderEditable]s it is attached to repaints. +/// * The [notifyListeners] method is called, which typically happens when the +/// painter's attributes change. +/// +/// See also: +/// +/// * [RenderEditable.foregroundPainter], which takes a [RenderEditablePainter] +/// and sets it as the foreground painter of the [RenderEditable]. +/// * [RenderEditable.painter], which takes a [RenderEditablePainter] +/// and sets it as the background painter of the [RenderEditable]. +/// * [CustomPainter], a similar class which paints within a [RenderCustomPaint]. +abstract class RenderEditablePainter extends ChangeNotifier { + /// Determines whether repaint is needed when a new [RenderEditablePainter] + /// is provided to a [RenderEditable]. + /// + /// If the new instance represents different information than the old + /// instance, then the method should return true, otherwise it should return + /// false. When [oldDelegate] is null, this method should always return true + /// unless the new painter initially does not paint anything. + /// + /// If the method returns false, then the [paint] call might be optimized + /// away. However, the [paint] method will get called whenever the + /// [RenderEditable]s it attaches to repaint, even if [shouldRepaint] returns + /// false. + bool shouldRepaint(RenderEditablePainter? oldDelegate); + + /// Paints within the bounds of a [RenderEditable]. + /// + /// The given [Canvas] has the same coordinate space as the [RenderEditable], + /// which may be different from the coordinate space the [RenderEditable]'s + /// [TextPainter] uses, when the text moves inside the [RenderEditable]. + /// + /// Paint operations performed outside of the region defined by the [canvas]'s + /// origin and the [size] parameter may get clipped, when [RenderEditable]'s + /// [RenderEditable.clipBehavior] is not [Clip.none]. + void paint(Canvas canvas, Size size, RenderEditable renderEditable); +} + +class _TextHighlightPainter extends RenderEditablePainter { + _TextHighlightPainter({TextRange? highlightedRange, Color? highlightColor}) + : _highlightedRange = highlightedRange, + _highlightColor = highlightColor; + + final Paint highlightPaint = Paint(); + + Color? get highlightColor => _highlightColor; + Color? _highlightColor; + set highlightColor(Color? newValue) { + if (newValue == _highlightColor) { + return; + } + _highlightColor = newValue; + notifyListeners(); + } + + TextRange? get highlightedRange => _highlightedRange; + TextRange? _highlightedRange; + set highlightedRange(TextRange? newValue) { + if (newValue == _highlightedRange) { + return; + } + _highlightedRange = newValue; + notifyListeners(); + } + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + ui.BoxHeightStyle get selectionHeightStyle => _selectionHeightStyle; + ui.BoxHeightStyle _selectionHeightStyle = ui.BoxHeightStyle.tight; + set selectionHeightStyle(ui.BoxHeightStyle value) { + if (_selectionHeightStyle == value) { + return; + } + _selectionHeightStyle = value; + notifyListeners(); + } + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + ui.BoxWidthStyle get selectionWidthStyle => _selectionWidthStyle; + ui.BoxWidthStyle _selectionWidthStyle = ui.BoxWidthStyle.tight; + set selectionWidthStyle(ui.BoxWidthStyle value) { + if (_selectionWidthStyle == value) { + return; + } + _selectionWidthStyle = value; + notifyListeners(); + } + + @override + void paint(Canvas canvas, Size size, RenderEditable renderEditable) { + final TextRange? range = highlightedRange; + final Color? color = highlightColor; + if (range == null || color == null || range.isCollapsed) { + return; + } + + highlightPaint.color = color; + final TextPainter textPainter = renderEditable._textPainter; + final List boxes = textPainter.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + boxHeightStyle: selectionHeightStyle, + boxWidthStyle: selectionWidthStyle, + ); + + for (final TextBox box in boxes) { + canvas.drawRect( + box.toRect().shift(renderEditable._paintOffset).intersect( + Rect.fromLTWH(0, 0, textPainter.width, textPainter.height), + ), + highlightPaint, + ); + } + } + + @override + bool shouldRepaint(RenderEditablePainter? oldDelegate) { + if (identical(oldDelegate, this)) { + return false; + } + if (oldDelegate == null) { + return highlightColor != null && highlightedRange != null; + } + return oldDelegate is! _TextHighlightPainter || + oldDelegate.highlightColor != highlightColor || + oldDelegate.highlightedRange != highlightedRange || + oldDelegate.selectionHeightStyle != selectionHeightStyle || + oldDelegate.selectionWidthStyle != selectionWidthStyle; + } +} + +class _CaretPainter extends RenderEditablePainter { + _CaretPainter(); + + bool get shouldPaint => _shouldPaint; + bool _shouldPaint = true; + set shouldPaint(bool value) { + if (shouldPaint == value) { + return; + } + _shouldPaint = value; + notifyListeners(); + } + + // This is directly manipulated by the RenderEditable during + // setFloatingCursor. + // + // When changing this value, the caller is responsible for ensuring that + // listeners are notified. + bool showRegularCaret = false; + + final Paint caretPaint = Paint(); + late final Paint floatingCursorPaint = Paint(); + + Color? get caretColor => _caretColor; + Color? _caretColor; + set caretColor(Color? value) { + if (caretColor?.value == value?.value) { + return; + } + + _caretColor = value; + notifyListeners(); + } + + Radius? get cursorRadius => _cursorRadius; + Radius? _cursorRadius; + set cursorRadius(Radius? value) { + if (_cursorRadius == value) { + return; + } + _cursorRadius = value; + notifyListeners(); + } + + Offset get cursorOffset => _cursorOffset; + Offset _cursorOffset = Offset.zero; + set cursorOffset(Offset value) { + if (_cursorOffset == value) { + return; + } + _cursorOffset = value; + notifyListeners(); + } + + Color? get backgroundCursorColor => _backgroundCursorColor; + Color? _backgroundCursorColor; + set backgroundCursorColor(Color? value) { + if (backgroundCursorColor?.value == value?.value) { + return; + } + + _backgroundCursorColor = value; + if (showRegularCaret) { + notifyListeners(); + } + } + + Rect? get floatingCursorRect => _floatingCursorRect; + Rect? _floatingCursorRect; + set floatingCursorRect(Rect? value) { + if (_floatingCursorRect == value) { + return; + } + _floatingCursorRect = value; + notifyListeners(); + } + + void paintRegularCursor( + Canvas canvas, + RenderEditable renderEditable, + Color caretColor, + TextPosition textPosition, + ) { + final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition); + if (shouldPaint) { + if (floatingCursorRect != null) { + final double distanceSquared = + (floatingCursorRect!.center - integralRect.center).distanceSquared; + if (distanceSquared < + _kShortestDistanceSquaredWithFloatingAndRegularCursors) { + return; + } + } + final Radius? radius = cursorRadius; + caretPaint.color = caretColor; + if (radius == null) { + canvas.drawRect(integralRect, caretPaint); + } else { + final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius); + canvas.drawRRect(caretRRect, caretPaint); + } + } + } + + @override + void paint(Canvas canvas, Size size, RenderEditable renderEditable) { + // Compute the caret location even when `shouldPaint` is false. + + final TextSelection? selection = renderEditable.selection; + + if (selection == null || !selection.isCollapsed || !selection.isValid) { + return; + } + + final Rect? floatingCursorRect = this.floatingCursorRect; + + final Color? caretColor = floatingCursorRect == null + ? this.caretColor + : showRegularCaret + ? backgroundCursorColor + : null; + final TextPosition caretTextPosition = floatingCursorRect == null + ? selection.extent + : renderEditable._floatingCursorTextPosition; + + if (caretColor != null) { + paintRegularCursor(canvas, renderEditable, caretColor, caretTextPosition); + } + + final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75); + // Floating Cursor. + if (floatingCursorRect == null || + floatingCursorColor == null || + !shouldPaint) { + return; + } + + canvas.drawRRect( + RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCursorRadius), + floatingCursorPaint..color = floatingCursorColor, + ); + } + + @override + bool shouldRepaint(RenderEditablePainter? oldDelegate) { + if (identical(this, oldDelegate)) { + return false; + } + + if (oldDelegate == null) { + return shouldPaint; + } + return oldDelegate is! _CaretPainter || + oldDelegate.shouldPaint != shouldPaint || + oldDelegate.showRegularCaret != showRegularCaret || + oldDelegate.caretColor != caretColor || + oldDelegate.cursorRadius != cursorRadius || + oldDelegate.cursorOffset != cursorOffset || + oldDelegate.backgroundCursorColor != backgroundCursorColor || + oldDelegate.floatingCursorRect != floatingCursorRect; + } +} + +class _CompositeRenderEditablePainter extends RenderEditablePainter { + _CompositeRenderEditablePainter({required this.painters}); + + final List painters; + + @override + void addListener(VoidCallback listener) { + for (final RenderEditablePainter painter in painters) { + painter.addListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + for (final RenderEditablePainter painter in painters) { + painter.removeListener(listener); + } + } + + @override + void paint(Canvas canvas, Size size, RenderEditable renderEditable) { + for (final RenderEditablePainter painter in painters) { + painter.paint(canvas, size, renderEditable); + } + } + + @override + bool shouldRepaint(RenderEditablePainter? oldDelegate) { + if (identical(oldDelegate, this)) { + return false; + } + if (oldDelegate is! _CompositeRenderEditablePainter || + oldDelegate.painters.length != painters.length) { + return true; + } + + final Iterator oldPainters = + oldDelegate.painters.iterator; + final Iterator newPainters = painters.iterator; + while (oldPainters.moveNext() && newPainters.moveNext()) { + if (newPainters.current.shouldRepaint(oldPainters.current)) { + return true; + } + } + + return false; + } +} diff --git a/lib/common/widgets/text_field/editable_text.dart b/lib/common/widgets/text_field/editable_text.dart index 913f7dd0..ba110cb2 100644 --- a/lib/common/widgets/text_field/editable_text.dart +++ b/lib/common/widgets/text_field/editable_text.dart @@ -21,12 +21,20 @@ import 'dart:math' as math; import 'dart:ui' as ui hide TextStyle; import 'dart:ui'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; +import 'package:PiliPlus/common/widgets/text_field/editable.dart'; import 'package:PiliPlus/common/widgets/text_field/spell_check.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_selection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' - hide SpellCheckConfiguration, buildTextSpanWithSpellCheckSuggestions; -import 'package:flutter/rendering.dart'; + hide + SpellCheckConfiguration, + buildTextSpanWithSpellCheckSuggestions, + TextSelectionOverlay, + TextSelectionGestureDetectorBuilder; +import 'package:flutter/rendering.dart' + hide RenderEditable, VerticalCaretMovementRun; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -124,7 +132,7 @@ class _RenderCompositionCallback extends RenderProxyBox { /// A controller for an editable text field. /// /// Whenever the user modifies a text field with an associated -/// [TextEditingController], the text field updates [value] and the controller +/// [RichTextEditingController], the text field updates [value] and the controller /// notifies its listeners. Listeners can then read the [text] and [selection] /// properties to learn what the user has typed or how the selection has been /// updated. @@ -132,7 +140,7 @@ class _RenderCompositionCallback extends RenderProxyBox { /// Similarly, if you modify the [text] or [selection] properties, the text /// field will be notified and will update itself appropriately. /// -/// A [TextEditingController] can also be used to provide an initial value for a +/// A [RichTextEditingController] can also be used to provide an initial value for a /// text field. If you build a text field with a controller that already has /// [text], the text field will use that text as its initial value. /// @@ -150,11 +158,11 @@ class _RenderCompositionCallback extends RenderProxyBox { /// controller's [value] instead. Setting [text] will clear the selection /// and composing range. /// -/// Remember to [dispose] of the [TextEditingController] when it is no longer +/// Remember to [dispose] of the [RichTextEditingController] when it is no longer /// needed. This will ensure we discard any resources used by the object. /// /// {@tool dartpad} -/// This example creates a [TextField] with a [TextEditingController] whose +/// This example creates a [TextField] with a [RichTextEditingController] whose /// change listener forces the entered text to be lower case and keeps the /// cursor at the end of the input. /// @@ -164,10 +172,10 @@ class _RenderCompositionCallback extends RenderProxyBox { /// See also: /// /// * [TextField], which is a Material Design text field that can be controlled -/// with a [TextEditingController]. +/// with a [RichTextEditingController]. /// * [EditableText], which is a raw region of editable text that can be -/// controlled with a [TextEditingController]. -/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). +/// controlled with a [RichTextEditingController]. +/// * Learn how to use a [RichTextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). // A time-value pair that represents a key frame in an animation. class _KeyFrame { @@ -273,7 +281,7 @@ class _DiscreteKeyFrameSimulation extends Simulation { /// /// * The [inputFormatters] will be first applied to the user input. /// -/// * The [controller]'s [TextEditingController.value] will be updated with the +/// * The [controller]'s [RichTextEditingController.value] will be updated with the /// formatted result, and the [controller]'s listeners will be notified. /// /// * The [onChanged] callback, if specified, will be called last. @@ -371,8 +379,8 @@ class _DiscreteKeyFrameSimulation extends Simulation { /// | **Intent Class** | **Default Behavior** | /// | :-------------------------------------- | :--------------------------------------------------- | /// | [DoNothingAndStopPropagationTextIntent] | Does nothing in the input field, and prevents the key event from further propagating in the widget tree. | -/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [TextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. | -/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [TextEditingController], and triggers the [onSelectionChanged] callback. | +/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [RichTextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. | +/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [RichTextEditingController], and triggers the [onSelectionChanged] callback. | /// | [CopySelectionTextIntent] | Copies or cuts the selected text into the clipboard | /// | [PasteTextIntent] | Inserts the current text in the clipboard after the caret location, or replaces the selected text if the selection is not collapsed. | /// @@ -573,8 +581,6 @@ class EditableText extends StatefulWidget { this.spellCheckConfiguration, this.magnifierConfiguration = TextMagnifierConfiguration.disabled, this.undoController, - this.onDelAtUser, - this.onMention, }) : assert(obscuringCharacter.length == 1), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), @@ -634,12 +640,8 @@ class EditableText extends StatefulWidget { : inputFormatters, showCursor = showCursor ?? !readOnly; - final VoidCallback? onMention; - - final ValueChanged? onDelAtUser; - /// Controls the text being edited. - final TextEditingController controller; + final RichTextEditingController controller; /// Controls whether this widget has keyboard focus. final FocusNode focusNode; @@ -1068,7 +1070,7 @@ class EditableText extends StatefulWidget { /// /// To be notified of all changes to the TextField's text, cursor, /// and selection, one can add a listener to its [controller] with - /// [TextEditingController.addListener]. + /// [RichTextEditingController.addListener]. /// /// [onChanged] is called before [onSubmitted] when user indicates completion /// of editing, such as when pressing the "done" button on the keyboard. That @@ -1104,7 +1106,7 @@ class EditableText extends StatefulWidget { /// runs and can validate and change ("format") the input value. /// * [onEditingComplete], [onSubmitted], [onSelectionChanged]: /// which are more specialized input change notifications. - /// * [TextEditingController], which implements the [Listenable] interface + /// * [RichTextEditingController], which implements the [Listenable] interface /// and notifies its listeners on [TextEditingValue] changes. final ValueChanged? onChanged; @@ -1268,7 +1270,7 @@ class EditableText extends StatefulWidget { /// /// See also: /// - /// * [TextEditingController], which implements the [Listenable] interface + /// * [RichTextEditingController], which implements the [Listenable] interface /// and notifies its listeners on [TextEditingValue] changes. /// {@endtemplate} final List? inputFormatters; @@ -1572,7 +1574,7 @@ class EditableText extends StatefulWidget { /// /// Persisting and restoring the content of the [EditableText] is the /// responsibility of the owner of the [controller], who may use a - /// [RestorableTextEditingController] for that purpose. + /// [RestorableRichTextEditingController] for that purpose. /// /// See also: /// @@ -1955,8 +1957,8 @@ class EditableText extends StatefulWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty('controller', controller)); + properties.add(DiagnosticsProperty( + 'controller', controller)); properties.add(DiagnosticsProperty('focusNode', focusNode)); properties.add(DiagnosticsProperty('obscureText', obscureText, defaultValue: false)); @@ -2373,7 +2375,8 @@ class EditableTextState extends State if (selection.isCollapsed || widget.obscureText) { return; } - final String text = textEditingValue.text; + // TODO copy + String text = textEditingValue.text; Clipboard.setData(ClipboardData(text: selection.textInside(text))); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); @@ -2388,6 +2391,7 @@ class EditableTextState extends State case TargetPlatform.android: case TargetPlatform.fuchsia: // Collapse the selection and hide the toolbar and handles. + userUpdateTextEditingValue( TextEditingValue( text: textEditingValue.text, @@ -2455,13 +2459,37 @@ class EditableTextState extends State final TextSelection selection = textEditingValue.selection; final int lastSelectionIndex = math.max(selection.baseOffset, selection.extentOffset); - final TextEditingValue collapsedTextEditingValue = - textEditingValue.copyWith( - selection: TextSelection.collapsed(offset: lastSelectionIndex), + // final TextEditingValue collapsedTextEditingValue = + // textEditingValue.copyWith( + // selection: TextSelection.collapsed(offset: lastSelectionIndex), + // ); + // final newValue = collapsedTextEditingValue.replaced(selection, text); + + widget.controller.syncRichText( + selection.isCollapsed + ? TextEditingDeltaInsertion( + oldText: textEditingValue.text, + textInserted: text, + insertionOffset: selection.baseOffset, + selection: TextSelection.collapsed(offset: lastSelectionIndex), + composing: TextRange.empty, + ) + : TextEditingDeltaReplacement( + oldText: textEditingValue.text, + replacementText: text, + replacedRange: selection, + selection: TextSelection.collapsed(offset: lastSelectionIndex), + composing: TextRange.empty, + ), ); - userUpdateTextEditingValue( - collapsedTextEditingValue.replaced(selection, text), cause); + final newValue = _value.copyWith( + text: widget.controller.plainText, + selection: widget.controller.newSelection, + ); + + userUpdateTextEditingValue(newValue, cause); + if (cause == SelectionChangedCause.toolbar) { // Schedule a call to bringIntoView() after renderEditable updates. SchedulerBinding.instance.addPostFrameCallback((_) { @@ -2481,6 +2509,7 @@ class EditableTextState extends State // selecting it. return; } + userUpdateTextEditingValue( textEditingValue.copyWith( selection: TextSelection( @@ -3165,7 +3194,7 @@ class EditableTextState extends State // everything else. value = _value.copyWith(selection: value.selection); } - _lastKnownRemoteTextEditingValue = value; + _lastKnownRemoteTextEditingValue = _value; if (value == _value) { // This is possible, for example, when the numeric keyboard is input, @@ -3257,47 +3286,25 @@ class EditableTextState extends State } } - static final _atUserRegex = RegExp(r'@[\u4e00-\u9fa5a-zA-Z\d_-]+ $'); - @override void updateEditingValueWithDeltas(List textEditingDeltas) { - var last = textEditingDeltas.lastOrNull; - if (last case TextEditingDeltaInsertion e) { - if (e.textInserted == '@') { - widget.onMention?.call(); - } - } else if (last case TextEditingDeltaDeletion e) { - if (e.textDeleted == ' ') { - final selection = _value.selection; - if (selection.isCollapsed) { - final text = _value.text; - final offset = selection.baseOffset; - - RegExpMatch? match = - _atUserRegex.firstMatch(text.substring(0, offset)); - - if (match != null) { - userUpdateTextEditingValue( - TextEditingDeltaDeletion( - oldText: e.oldText, - deletedRange: TextRange(start: match.start, end: match.end), - selection: TextSelection.collapsed(offset: match.start), - composing: e.composing, - ).apply(_value), - SelectionChangedCause.keyboard, - ); - widget.onDelAtUser?.call(match.group(0)!.trim()); - return; - } - } - } - } - - TextEditingValue value = _value; for (final TextEditingDelta delta in textEditingDeltas) { - value = delta.apply(value); + widget.controller.syncRichText(delta); } - updateEditingValue(value); + + final newValue = _value.copyWith( + text: widget.controller.plainText, + selection: widget.controller.newSelection, + composing: textEditingDeltas.lastOrNull?.composing, + ); + + updateEditingValue(newValue); + + // TextEditingValue value = _value; + // for (final TextEditingDelta delta in textEditingDeltas) { + // value = delta.apply(value); + // } + // updateEditingValue(value); } @override @@ -3388,6 +3395,10 @@ class EditableTextState extends State renderEditable .localToGlobal(_lastBoundedOffset! + _floatingCursorOffset), ); + + // bggRGjQaUbCoE ios single long press + _lastTextPosition = widget.controller.dragOffset(_lastTextPosition!); + renderEditable.setFloatingCursor( point.state, _lastBoundedOffset!, _lastTextPosition!); case FloatingCursorDragState.End: @@ -4005,6 +4016,7 @@ class EditableTextState extends State final EditableTextContextMenuBuilder? contextMenuBuilder = widget.contextMenuBuilder; final TextSelectionOverlay selectionOverlay = TextSelectionOverlay( + controller: widget.controller, clipboardStatus: clipboardStatus, context: context, value: _value, @@ -4248,6 +4260,15 @@ class EditableTextState extends State final bool textCommitted = !oldValue.composing.isCollapsed && value.composing.isCollapsed; final bool selectionChanged = oldValue.selection != value.selection; + // if (!textChanged && selectionChanged) { + // value = value.copyWith( + // selection: widget.controller.updateSelection( + // oldSelection: _value.selection, + // newSelection: value.selection, + // cause: cause, + // ), + // ); + // } if (textChanged || textCommitted) { // Only apply input formatters if the text has changed (including uncommitted @@ -5144,10 +5165,34 @@ class EditableTextState extends State void _replaceText(ReplaceTextIntent intent) { final TextEditingValue oldValue = _value; - final TextEditingValue newValue = intent.currentTextEditingValue.replaced( - intent.replacementRange, - intent.replacementText, + // final TextEditingValue newValue = intent.currentTextEditingValue.replaced( + // intent.replacementRange, + // intent.replacementText, + // ); + widget.controller.syncRichText( + intent.replacementText.isEmpty + ? TextEditingDeltaDeletion( + oldText: oldValue.text, + deletedRange: intent.replacementRange, + selection: TextSelection.collapsed( + offset: intent.replacementRange.start), + composing: TextRange.empty, + ) + : TextEditingDeltaReplacement( + oldText: oldValue.text, + replacementText: intent.replacementText, + replacedRange: intent.replacementRange, + selection: TextSelection.collapsed( + offset: intent.replacementRange.start), + composing: TextRange.empty, + ), ); + + final newValue = oldValue.copyWith( + text: widget.controller.plainText, + selection: widget.controller.newSelection, + ); + userUpdateTextEditingValue(newValue, intent.cause); // If there's no change in text and selection (e.g. when selecting and @@ -5258,6 +5303,7 @@ class EditableTextState extends State } bringIntoView(nextSelection.extent); + userUpdateTextEditingValue( _value.copyWith(selection: nextSelection), SelectionChangedCause.keyboard, @@ -5275,8 +5321,17 @@ class EditableTextState extends State ); bringIntoView(intent.newSelection.extent); + + // bggRGjQaUbCoE keyboard + TextSelection newSelection = intent.newSelection; + if (newSelection.isCollapsed) { + newSelection = widget.controller.keyboardOffset(newSelection); + } else { + newSelection = widget.controller.keyboardOffsets(newSelection); + } + userUpdateTextEditingValue( - intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.currentTextEditingValue.copyWith(selection: newSelection), intent.cause, ); } @@ -5358,8 +5413,6 @@ class EditableTextState extends State this, _characterBoundary, _moveBeyondTextBoundary, - atUserRegex: _atUserRegex, - onDelAtUser: widget.onDelAtUser, ), ), DeleteToNextWordBoundaryIntent: _makeOverridable( @@ -5623,6 +5676,7 @@ class EditableTextState extends State child: SizeChangedLayoutNotifier( child: _Editable( key: _editableKey, + controller: widget.controller, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, inlineSpan: buildTextSpan(), @@ -5823,6 +5877,7 @@ class _Editable extends MultiChildRenderObjectWidget { this.promptRectRange, this.promptRectColor, required this.clipBehavior, + required this.controller, }) : super( children: WidgetSpan.extractFromInlineSpan(inlineSpan, textScaler)); @@ -5864,10 +5919,12 @@ class _Editable extends MultiChildRenderObjectWidget { final TextRange? promptRectRange; final Color? promptRectColor; final Clip clipBehavior; + final RichTextEditingController controller; @override RenderEditable createRenderObject(BuildContext context) { return RenderEditable( + controller: controller, text: inlineSpan, cursorColor: cursorColor, startHandleLayerLink: startHandleLayerLink, @@ -6200,16 +6257,12 @@ class _DeleteTextAction _DeleteTextAction( this.state, this.getTextBoundary, - this._applyTextBoundary, { - this.atUserRegex, - this.onDelAtUser, - }); + this._applyTextBoundary, + ); final EditableTextState state; final TextBoundary Function() getTextBoundary; final _ApplyTextBoundary _applyTextBoundary; - final RegExp? atUserRegex; - final ValueChanged? onDelAtUser; void _hideToolbarIfTextChanged(ReplaceTextIntent intent) { if (state._selectionOverlay == null || @@ -6255,28 +6308,6 @@ class _DeleteTextAction return Actions.invoke(context!, replaceTextIntent); } - final value = state._value; - final text = value.text; - - if (!intent.forward) { - if (text.isNotEmpty && selection.baseOffset != 0) { - String subText = text.substring(0, selection.baseOffset); - RegExpMatch? match = atUserRegex?.firstMatch(subText); - if (match != null) { - onDelAtUser?.call(match.group(0)!.trim()); - final range = TextRange(start: match.start, end: match.end); - final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent( - value, - '', - range, - SelectionChangedCause.keyboard, - ); - _hideToolbarIfTextChanged(replaceTextIntent); - return Actions.invoke(context!, replaceTextIntent); - } - } - } - final int target = _applyTextBoundary(selection.base, intent.forward, getTextBoundary()) .offset; @@ -6284,14 +6315,14 @@ class _DeleteTextAction final TextRange rangeToDelete = TextSelection( baseOffset: intent.forward ? atomicBoundary.getLeadingTextBoundaryAt(selection.baseOffset) ?? - text.length + state._value.text.length : atomicBoundary .getTrailingTextBoundaryAt(selection.baseOffset - 1) ?? 0, extentOffset: target, ); final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent( - value, + state._value, '', rangeToDelete, SelectionChangedCause.keyboard, diff --git a/lib/common/widgets/text_field/text_field.dart b/lib/common/widgets/text_field/text_field.dart index 1925d2dd..26c3811f 100644 --- a/lib/common/widgets/text_field/text_field.dart +++ b/lib/common/widgets/text_field/text_field.dart @@ -14,6 +14,7 @@ library; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; import 'package:PiliPlus/common/widgets/text_field/adaptive_text_selection_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart'; import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_text_field.dart'; import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; @@ -32,7 +33,8 @@ import 'package:flutter/cupertino.dart' buildTextSpanWithSpellCheckSuggestions, CupertinoTextField, TextSelectionGestureDetectorBuilderDelegate, - TextSelectionGestureDetectorBuilder; + TextSelectionGestureDetectorBuilder, + TextSelectionOverlay; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' @@ -46,7 +48,8 @@ import 'package:flutter/material.dart' EditableTextContextMenuBuilder, buildTextSpanWithSpellCheckSuggestions, TextSelectionGestureDetectorBuilderDelegate, - TextSelectionGestureDetectorBuilder; + TextSelectionGestureDetectorBuilder, + TextSelectionOverlay; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -62,7 +65,7 @@ export 'package:flutter/services.dart' // late BuildContext context; // late FocusNode myFocusNode; -/// Signature for the [TextField.buildCounter] callback. +/// Signature for the [RichTextField.buildCounter] callback. typedef InputCounterWidgetBuilder = Widget? Function( /// The build context for the TextField. BuildContext context, { @@ -79,11 +82,12 @@ typedef InputCounterWidgetBuilder = Widget? Function( class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { - _TextFieldSelectionGestureDetectorBuilder({required _TextFieldState state}) + _TextFieldSelectionGestureDetectorBuilder( + {required _RichTextFieldState state}) : _state = state, super(delegate: state); - final _TextFieldState _state; + final _RichTextFieldState _state; @override bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled; @@ -119,7 +123,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// If [decoration] is non-null (which is the default), the text field requires /// one of its ancestors to be a [Material] widget. /// -/// To integrate the [TextField] into a [Form] with other [FormField] widgets, +/// To integrate the [RichTextField] into a [Form] with other [FormField] widgets, /// consider using [TextFormField]. /// /// {@template flutter.material.textfield.wantKeepAlive} @@ -129,7 +133,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// disposed. /// {@endtemplate} /// -/// Remember to call [TextEditingController.dispose] on the [TextEditingController] +/// Remember to call [RichTextEditingController.dispose] on the [RichTextEditingController] /// when it is no longer needed. This will ensure we discard any resources used /// by the object. /// @@ -141,7 +145,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// ## Obscured Input /// /// {@tool dartpad} -/// This example shows how to create a [TextField] that will obscure input. The +/// This example shows how to create a [RichTextField] that will obscure input. The /// [InputDecoration] surrounds the field in a border using [OutlineInputBorder] /// and adds a label. /// @@ -173,7 +177,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// callback. /// /// Keep in mind you can also always read the current string from a TextField's -/// [TextEditingController] using [TextEditingController.text]. +/// [RichTextEditingController] using [RichTextEditingController.text]. /// /// ## Handling emojis and other complex characters /// {@macro flutter.widgets.EditableText.onChanged} @@ -196,10 +200,10 @@ class _TextFieldSelectionGestureDetectorBuilder /// /// ## Scrolling Considerations /// -/// If this [TextField] is not a descendant of [Scaffold] and is being used +/// If this [RichTextField] is not a descendant of [Scaffold] and is being used /// within a [Scrollable] or nested [Scrollable]s, consider placing a /// [ScrollNotificationObserver] above the root [Scrollable] that contains this -/// [TextField] to ensure proper scroll coordination for [TextField] and its +/// [RichTextField] to ensure proper scroll coordination for [RichTextField] and its /// components like [TextSelectionOverlay]. /// /// See also: @@ -208,7 +212,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// * [InputDecorator], which shows the labels and other visual elements that /// surround the actual text editing widget. /// * [EditableText], which is the raw text editing control at the heart of a -/// [TextField]. The [EditableText] widget is rarely used directly unless +/// [RichTextField]. The [EditableText] widget is rarely used directly unless /// you are implementing an entirely different design language, such as /// Cupertino. /// * @@ -216,7 +220,7 @@ class _TextFieldSelectionGestureDetectorBuilder /// * Cookbook: [Handle changes to a text field](https://docs.flutter.dev/cookbook/forms/text-field-changes) /// * Cookbook: [Retrieve the value of a text field](https://docs.flutter.dev/cookbook/forms/retrieve-input) /// * Cookbook: [Focus and text fields](https://docs.flutter.dev/cookbook/forms/focus) -class TextField extends StatefulWidget { +class RichTextField extends StatefulWidget { /// Creates a Material Design text field. /// /// If [decoration] is non-null (which is the default), the text field requires @@ -236,7 +240,7 @@ class TextField extends StatefulWidget { /// field showing how many characters have been entered. If the value is /// set to a positive integer it will also display the maximum allowed /// number of characters to be entered. If the value is set to - /// [TextField.noMaxLength] then only the current length is displayed. + /// [RichTextField.noMaxLength] then only the current length is displayed. /// /// After [maxLength] characters have been input, additional input /// is ignored, unless [maxLengthEnforcement] is set to @@ -261,10 +265,10 @@ class TextField extends StatefulWidget { /// /// * [maxLength], which discusses the precise meaning of "number of /// characters" and how it may differ from the intuitive meaning. - const TextField({ + const RichTextField({ super.key, this.groupId = EditableText, - this.controller, + required this.controller, this.focusNode, this.undoController, this.decoration = const InputDecoration(), @@ -340,8 +344,6 @@ class TextField extends StatefulWidget { this.canRequestFocus = true, this.spellCheckConfiguration, this.magnifierConfiguration, - this.onDelAtUser, - this.onMention, }) : assert(obscuringCharacter.length == 1), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), @@ -360,7 +362,7 @@ class TextField extends StatefulWidget { assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), assert(maxLength == null || - maxLength == TextField.noMaxLength || + maxLength == RichTextField.noMaxLength || maxLength > 0), // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. assert( @@ -374,10 +376,6 @@ class TextField extends StatefulWidget { enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); - final VoidCallback? onMention; - - final ValueChanged? onDelAtUser; - /// The configuration for the magnifier of this text field. /// /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] @@ -398,8 +396,8 @@ class TextField extends StatefulWidget { /// Controls the text being edited. /// - /// If null, this widget will create its own [TextEditingController]. - final TextEditingController? controller; + /// If null, this widget will create its own [RichTextEditingController]. + final RichTextEditingController controller; /// Defines the keyboard focus for this widget. /// @@ -570,7 +568,7 @@ class TextField extends StatefulWidget { /// If set, a character counter will be displayed below the /// field showing how many characters have been entered. If set to a number /// greater than 0, it will also display the maximum number allowed. If set - /// to [TextField.noMaxLength] then only the current character count is displayed. + /// to [RichTextField.noMaxLength] then only the current character count is displayed. /// /// After [maxLength] characters have been input, additional input /// is ignored, unless [maxLengthEnforcement] is set to @@ -579,9 +577,9 @@ class TextField extends StatefulWidget { /// The text field enforces the length with a [LengthLimitingTextInputFormatter], /// which is evaluated after the supplied [inputFormatters], if any. /// - /// This value must be either null, [TextField.noMaxLength], or greater than 0. + /// This value must be either null, [RichTextField.noMaxLength], or greater than 0. /// If null (the default) then there is no limit to the number of characters - /// that can be entered. If set to [TextField.noMaxLength], then no limit will + /// that can be entered. If set to [RichTextField.noMaxLength], then no limit will /// be enforced, but the number of characters entered will still be displayed. /// /// Whitespace characters (e.g. newline, space, tab) are included in the @@ -740,7 +738,7 @@ class TextField extends StatefulWidget { /// /// {@tool dartpad} /// This example shows how to use a `TextFieldTapRegion` to wrap a set of - /// "spinner" buttons that increment and decrement a value in the [TextField] + /// "spinner" buttons that increment and decrement a value in the [RichTextField] /// without causing the text field to lose keyboard focus. /// /// This example includes a generic `SpinnerField` class that you can copy @@ -770,7 +768,7 @@ class TextField extends StatefulWidget { /// /// If this property is null, [WidgetStateMouseCursor.textable] will be used. /// - /// The [mouseCursor] is the only property of [TextField] that controls the + /// The [mouseCursor] is the only property of [RichTextField] that controls the /// appearance of the mouse pointer. All other properties related to "cursor" /// stand for the text cursor, which is usually a blinking vertical line at /// the editing position. @@ -903,7 +901,7 @@ class TextField extends StatefulWidget { /// See also: /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to /// mark misspelled words with. - /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured + /// * [CupertinoRichTextField.cupertinoMisspelledTextStyle], the style configured /// to mark misspelled words with in the Cupertino style. static const TextStyle materialMisspelledTextStyle = TextStyle( decoration: TextDecoration.underline, @@ -911,18 +909,18 @@ class TextField extends StatefulWidget { decorationStyle: TextDecorationStyle.wavy, ); - /// Default builder for [TextField]'s spell check suggestions toolbar. + /// Default builder for [RichTextField]'s spell check suggestions toolbar. /// /// On Apple platforms, builds an iOS-style toolbar. Everywhere else, builds /// an Android-style toolbar. /// /// See also: /// * [spellCheckConfiguration], where this is typically specified for - /// [TextField]. + /// [RichTextField]. /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the - /// parameter for which this is the default value for [TextField]. - /// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], which - /// is like this but specifies the default for [CupertinoTextField]. + /// parameter for which this is the default value for [RichTextField]. + /// * [CupertinoRichTextField.defaultSpellCheckSuggestionsToolbarBuilder], which + /// is like this but specifies the default for [CupertinoRichTextField]. @visibleForTesting static Widget defaultSpellCheckSuggestionsToolbarBuilder( BuildContext context, @@ -955,22 +953,22 @@ class TextField extends StatefulWidget { } return configuration.copyWith( misspelledTextStyle: configuration.misspelledTextStyle ?? - TextField.materialMisspelledTextStyle, + RichTextField.materialMisspelledTextStyle, spellCheckSuggestionsToolbarBuilder: configuration.spellCheckSuggestionsToolbarBuilder ?? - TextField.defaultSpellCheckSuggestionsToolbarBuilder, + RichTextField.defaultSpellCheckSuggestionsToolbarBuilder, ); } @override - State createState() => _TextFieldState(); + State createState() => _RichTextFieldState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add( - DiagnosticsProperty('controller', controller, + DiagnosticsProperty('controller', controller, defaultValue: null), ) ..add(DiagnosticsProperty('focusNode', focusNode, @@ -1152,12 +1150,12 @@ class TextField extends StatefulWidget { } } -class _TextFieldState extends State +class _RichTextFieldState extends State with RestorationMixin implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { - RestorableTextEditingController? _controller; - TextEditingController get _effectiveController => - widget.controller ?? _controller!.value; + // RestorableRichTextEditingController? _controller; + RichTextEditingController get _effectiveController => widget.controller; + // widget.controller ?? _controller!.value; FocusNode? _focusNode; FocusNode get _effectiveFocusNode => @@ -1199,11 +1197,13 @@ class _TextFieldState extends State bool get _hasIntrinsicError => widget.maxLength != null && widget.maxLength! > 0 && - (widget.controller == null - ? !restorePending && - _effectiveController.value.text.characters.length > - widget.maxLength! - : _effectiveController.value.text.characters.length > + ( + // widget.controller == null + // ? !restorePending && + // _effectiveController.value.text.characters.length > + // widget.maxLength! + // : + _effectiveController.value.text.characters.length > widget.maxLength!); bool get _hasError => @@ -1295,9 +1295,9 @@ class _TextFieldState extends State super.initState(); _selectionGestureDetectorBuilder = _TextFieldSelectionGestureDetectorBuilder(state: this); - if (widget.controller == null) { - _createLocalController(); - } + // if (widget.controller == null) { + // _createLocalController(); + // } _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; _effectiveFocusNode.addListener(_handleFocusChanged); _initStatesController(); @@ -1319,15 +1319,15 @@ class _TextFieldState extends State } @override - void didUpdateWidget(TextField oldWidget) { + void didUpdateWidget(RichTextField oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.controller == null && oldWidget.controller != null) { - _createLocalController(oldWidget.controller!.value); - } else if (widget.controller != null && oldWidget.controller == null) { - unregisterFromRestoration(_controller!); - _controller!.dispose(); - _controller = null; - } + // if (widget.controller == null && oldWidget.controller != null) { + // _createLocalController(oldWidget.controller!.value); + // } else if (widget.controller != null && oldWidget.controller == null) { + // unregisterFromRestoration(_controller!); + // _controller!.dispose(); + // _controller = null; + // } if (widget.focusNode != oldWidget.focusNode) { (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); @@ -1345,11 +1345,11 @@ class _TextFieldState extends State } if (widget.statesController == oldWidget.statesController) { - _statesController.update(MaterialState.disabled, !_isEnabled); - _statesController.update(MaterialState.hovered, _isHovering); - _statesController.update( - MaterialState.focused, _effectiveFocusNode.hasFocus); - _statesController.update(MaterialState.error, _hasError); + _statesController + ..update(MaterialState.disabled, !_isEnabled) + ..update(MaterialState.hovered, _isHovering) + ..update(MaterialState.focused, _effectiveFocusNode.hasFocus) + ..update(MaterialState.error, _hasError); } else { oldWidget.statesController?.removeListener(_handleStatesControllerChange); if (widget.statesController != null) { @@ -1362,25 +1362,25 @@ class _TextFieldState extends State @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - if (_controller != null) { - _registerController(); - } + // if (_controller != null) { + // _registerController(); + // } } - void _registerController() { - assert(_controller != null); - registerForRestoration(_controller!, 'controller'); - } + // void _registerController() { + // assert(_controller != null); + // registerForRestoration(_controller!, 'controller'); + // } - void _createLocalController([TextEditingValue? value]) { - assert(_controller == null); - _controller = value == null - ? RestorableTextEditingController() - : RestorableTextEditingController.fromValue(value); - if (!restorePending) { - _registerController(); - } - } + // void _createLocalController([TextEditingValue? value]) { + // assert(_controller == null); + // _controller = value == null + // ? RestorableRichTextEditingController() + // : RestorableRichTextEditingController.fromValue(value); + // if (!restorePending) { + // _registerController(); + // } + // } @override String? get restorationId => widget.restorationId; @@ -1389,7 +1389,7 @@ class _TextFieldState extends State void dispose() { _effectiveFocusNode.removeListener(_handleFocusChanged); _focusNode?.dispose(); - _controller?.dispose(); + // _controller?.dispose(); _statesController.removeListener(_handleStatesControllerChange); _internalStatesController?.dispose(); super.dispose(); @@ -1582,7 +1582,7 @@ class _TextFieldState extends State ).merge(providedStyle); final Brightness keyboardAppearance = widget.keyboardAppearance ?? theme.brightness; - final TextEditingController controller = _effectiveController; + final RichTextEditingController controller = _effectiveController; final FocusNode focusNode = _effectiveFocusNode; final List formatters = [ ...?widget.inputFormatters, @@ -1601,14 +1601,15 @@ class _TextFieldState extends State case TargetPlatform.iOS: case TargetPlatform.macOS: spellCheckConfiguration = - CupertinoTextField.inferIOSSpellCheckConfiguration( + CupertinoRichTextField.inferIOSSpellCheckConfiguration( widget.spellCheckConfiguration, ); case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration( + spellCheckConfiguration = + RichTextField.inferAndroidSpellCheckConfiguration( widget.spellCheckConfiguration, ); } @@ -1804,8 +1805,6 @@ class _TextFieldState extends State spellCheckConfiguration: spellCheckConfiguration, magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, - onDelAtUser: widget.onDelAtUser, - onMention: widget.onMention, ), ), ); diff --git a/lib/common/widgets/text_field/text_selection.dart b/lib/common/widgets/text_field/text_selection.dart index 683a5cef..ef149f91 100644 --- a/lib/common/widgets/text_field/text_selection.dart +++ b/lib/common/widgets/text_field/text_selection.dart @@ -1,10 +1,13 @@ import 'dart:math' as math; import 'dart:ui'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; +import 'package:PiliPlus/common/widgets/text_field/editable.dart'; import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; +import 'package:flutter/material.dart' show kMinInteractiveDimension; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' hide EditableText, EditableTextState; @@ -1471,3 +1474,1732 @@ class _TextSelectionGestureDetectorState ); } } + +/// An object that manages a pair of text selection handles for a +/// [RenderEditable]. +/// +/// This class is a wrapper of [SelectionOverlay] to provide APIs specific for +/// [RenderEditable]s. To manage selection handles for custom widgets, use +/// [SelectionOverlay] instead. +class TextSelectionOverlay { + /// Creates an object that manages overlay entries for selection handles. + /// + /// The [context] must have an [Overlay] as an ancestor. + TextSelectionOverlay({ + required TextEditingValue value, + required this.context, + Widget? debugRequiredFor, + required LayerLink toolbarLayerLink, + required LayerLink startHandleLayerLink, + required LayerLink endHandleLayerLink, + required this.renderObject, + this.selectionControls, + bool handlesVisible = false, + required this.selectionDelegate, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + VoidCallback? onSelectionHandleTapped, + ClipboardStatusNotifier? clipboardStatus, + this.contextMenuBuilder, + required TextMagnifierConfiguration magnifierConfiguration, + required this.controller, + }) : _handlesVisible = handlesVisible, + _value = value { + assert(debugMaybeDispatchCreated('widgets', 'TextSelectionOverlay', this)); + renderObject.selectionStartInViewport + .addListener(_updateTextSelectionOverlayVisibilities); + renderObject.selectionEndInViewport + .addListener(_updateTextSelectionOverlayVisibilities); + _updateTextSelectionOverlayVisibilities(); + _selectionOverlay = SelectionOverlay( + magnifierConfiguration: magnifierConfiguration, + context: context, + debugRequiredFor: debugRequiredFor, + // The metrics will be set when show handles. + startHandleType: TextSelectionHandleType.collapsed, + startHandlesVisible: _effectiveStartHandleVisibility, + lineHeightAtStart: 0.0, + onStartHandleDragStart: _handleSelectionStartHandleDragStart, + onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, + onEndHandleDragEnd: _handleAnyDragEnd, + endHandleType: TextSelectionHandleType.collapsed, + endHandlesVisible: _effectiveEndHandleVisibility, + lineHeightAtEnd: 0.0, + onEndHandleDragStart: _handleSelectionEndHandleDragStart, + onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + onStartHandleDragEnd: _handleAnyDragEnd, + toolbarVisible: _effectiveToolbarVisibility, + selectionEndpoints: const [], + selectionControls: selectionControls, + selectionDelegate: selectionDelegate, + clipboardStatus: clipboardStatus, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + toolbarLayerLink: toolbarLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + dragStartBehavior: dragStartBehavior, + toolbarLocation: renderObject.lastSecondaryTapDownPosition, + ); + } + + final RichTextEditingController controller; + + /// {@template flutter.widgets.SelectionOverlay.context} + /// The context in which the selection UI should appear. + /// + /// This context must have an [Overlay] as an ancestor because this object + /// will display the text selection handles in that [Overlay]. + /// {@endtemplate} + final BuildContext context; + + // TODO(mpcomplete): what if the renderObject is removed or replaced, or + // moves? Not sure what cases I need to handle, or how to handle them. + /// The editable line in which the selected text is being displayed. + final RenderEditable renderObject; + + /// {@macro flutter.widgets.SelectionOverlay.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.SelectionOverlay.selectionDelegate} + final TextSelectionDelegate selectionDelegate; + + late final SelectionOverlay _selectionOverlay; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, no context menu will be built. + final WidgetBuilder? contextMenuBuilder; + + /// Retrieve current value. + @visibleForTesting + TextEditingValue get value => _value; + + TextEditingValue _value; + + TextSelection get _selection => _value.selection; + + final ValueNotifier _effectiveStartHandleVisibility = + ValueNotifier(false); + final ValueNotifier _effectiveEndHandleVisibility = + ValueNotifier(false); + final ValueNotifier _effectiveToolbarVisibility = + ValueNotifier(false); + + void _updateTextSelectionOverlayVisibilities() { + _effectiveStartHandleVisibility.value = + _handlesVisible && renderObject.selectionStartInViewport.value; + _effectiveEndHandleVisibility.value = + _handlesVisible && renderObject.selectionEndInViewport.value; + _effectiveToolbarVisibility.value = + renderObject.selectionStartInViewport.value || + renderObject.selectionEndInViewport.value; + } + + /// Whether selection handles are visible. + /// + /// Set to false if you want to hide the handles. Use this property to show or + /// hide the handle without rebuilding them. + /// + /// Defaults to false. + bool get handlesVisible => _handlesVisible; + bool _handlesVisible = false; + set handlesVisible(bool visible) { + if (_handlesVisible == visible) { + return; + } + _handlesVisible = visible; + _updateTextSelectionOverlayVisibilities(); + } + + /// {@macro flutter.widgets.SelectionOverlay.showHandles} + void showHandles() { + _updateSelectionOverlay(); + _selectionOverlay.showHandles(); + } + + /// {@macro flutter.widgets.SelectionOverlay.hideHandles} + void hideHandles() => _selectionOverlay.hideHandles(); + + /// {@macro flutter.widgets.SelectionOverlay.showToolbar} + void showToolbar() { + _updateSelectionOverlay(); + + if (selectionControls != null && + selectionControls is! TextSelectionHandleControls) { + _selectionOverlay.showToolbar(); + return; + } + + if (contextMenuBuilder == null) { + return; + } + + assert(context.mounted); + _selectionOverlay.showToolbar( + context: context, contextMenuBuilder: contextMenuBuilder); + return; + } + + /// Shows toolbar with spell check suggestions of misspelled words that are + /// available for click-and-replace. + void showSpellCheckSuggestionsToolbar( + WidgetBuilder spellCheckSuggestionsToolbarBuilder) { + _updateSelectionOverlay(); + assert(context.mounted); + _selectionOverlay.showSpellCheckSuggestionsToolbar( + context: context, + builder: spellCheckSuggestionsToolbarBuilder, + ); + hideHandles(); + } + + /// {@macro flutter.widgets.SelectionOverlay.showMagnifier} + void showMagnifier(Offset positionToShow) { + final TextPosition position = + renderObject.getPositionForPoint(positionToShow); + _updateSelectionOverlay(); + _selectionOverlay.showMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: positionToShow, + renderEditable: renderObject, + ), + ); + } + + /// {@macro flutter.widgets.SelectionOverlay.updateMagnifier} + void updateMagnifier(Offset positionToShow) { + final TextPosition position = + renderObject.getPositionForPoint(positionToShow); + _updateSelectionOverlay(); + _selectionOverlay.updateMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: positionToShow, + renderEditable: renderObject, + ), + ); + } + + /// {@macro flutter.widgets.SelectionOverlay.hideMagnifier} + void hideMagnifier() { + _selectionOverlay.hideMagnifier(); + } + + /// Updates the overlay after the selection has changed. + /// + /// If this method is called while the [SchedulerBinding.schedulerPhase] is + /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or + /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed + /// until the post-frame callbacks phase. Otherwise the update is done + /// synchronously. This means that it is safe to call during builds, but also + /// that if you do call this during a build, the UI will not update until the + /// next frame (i.e. many milliseconds later). + void update(TextEditingValue newValue) { + if (_value == newValue) { + return; + } + _value = newValue; + _updateSelectionOverlay(); + // _updateSelectionOverlay may not rebuild the selection overlay if the + // text metrics and selection doesn't change even if the text has changed. + // This rebuild is needed for the toolbar to update based on the latest text + // value. + _selectionOverlay.markNeedsBuild(); + } + + void _updateSelectionOverlay() { + _selectionOverlay + // Update selection handle metrics. + ..startHandleType = _chooseType( + renderObject.textDirection, + TextSelectionHandleType.left, + TextSelectionHandleType.right, + ) + ..lineHeightAtStart = _getStartGlyphHeight() + ..endHandleType = _chooseType( + renderObject.textDirection, + TextSelectionHandleType.right, + TextSelectionHandleType.left, + ) + ..lineHeightAtEnd = _getEndGlyphHeight() + // Update selection toolbar metrics. + ..selectionEndpoints = renderObject.getEndpointsForSelection(_selection) + ..toolbarLocation = renderObject.lastSecondaryTapDownPosition; + } + + /// Causes the overlay to update its rendering. + /// + /// This is intended to be called when the [renderObject] may have changed its + /// text metrics (e.g. because the text was scrolled). + void updateForScroll() { + _updateSelectionOverlay(); + // This method may be called due to windows metrics changes. In that case, + // non of the properties in _selectionOverlay will change, but a rebuild is + // still needed. + _selectionOverlay.markNeedsBuild(); + } + + /// Whether the handles are currently visible. + bool get handlesAreVisible => + _selectionOverlay._handles != null && handlesVisible; + + /// {@macro flutter.widgets.SelectionOverlay.toolbarIsVisible} + /// + /// See also: + /// + /// * [spellCheckToolbarIsVisible], which is only whether the spell check menu + /// specifically is visible. + bool get toolbarIsVisible => _selectionOverlay.toolbarIsVisible; + + /// Whether the magnifier is currently visible. + bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown; + + /// Whether the spell check menu is currently visible. + /// + /// See also: + /// + /// * [toolbarIsVisible], which is whether any toolbar is visible. + bool get spellCheckToolbarIsVisible => + _selectionOverlay._spellCheckToolbarController.isShown; + + /// {@macro flutter.widgets.SelectionOverlay.hide} + void hide() => _selectionOverlay.hide(); + + /// {@macro flutter.widgets.SelectionOverlay.hideToolbar} + void hideToolbar() => _selectionOverlay.hideToolbar(); + + /// {@macro flutter.widgets.SelectionOverlay.dispose} + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + _selectionOverlay.dispose(); + renderObject.selectionStartInViewport + .removeListener(_updateTextSelectionOverlayVisibilities); + renderObject.selectionEndInViewport + .removeListener(_updateTextSelectionOverlayVisibilities); + _effectiveToolbarVisibility.dispose(); + _effectiveStartHandleVisibility.dispose(); + _effectiveEndHandleVisibility.dispose(); + hideToolbar(); + } + + double _getStartGlyphHeight() { + final String currText = selectionDelegate.textEditingValue.text; + final int firstSelectedGraphemeExtent; + Rect? startHandleRect; + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + if (renderObject.plainText == currText && + _selection.isValid && + !_selection.isCollapsed) { + final String selectedGraphemes = _selection.textInside(currText); + firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length; + startHandleRect = renderObject.getRectForComposingRange( + TextRange( + start: _selection.start, + end: _selection.start + firstSelectedGraphemeExtent), + ); + } + return startHandleRect?.height ?? renderObject.preferredLineHeight; + } + + double _getEndGlyphHeight() { + final String currText = selectionDelegate.textEditingValue.text; + final int lastSelectedGraphemeExtent; + Rect? endHandleRect; + // See the explanation in _getStartGlyphHeight. + if (renderObject.plainText == currText && + _selection.isValid && + !_selection.isCollapsed) { + final String selectedGraphemes = _selection.textInside(currText); + lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length; + endHandleRect = renderObject.getRectForComposingRange( + TextRange( + start: _selection.end - lastSelectedGraphemeExtent, + end: _selection.end), + ); + } + return endHandleRect?.height ?? renderObject.preferredLineHeight; + } + + MagnifierInfo _buildMagnifier({ + required RenderEditable renderEditable, + required Offset globalGesturePosition, + required TextPosition currentTextPosition, + }) { + final TextSelection lineAtOffset = + renderEditable.getLineAtOffset(currentTextPosition); + final TextPosition positionAtEndOfLine = TextPosition( + offset: lineAtOffset.extentOffset, + affinity: TextAffinity.upstream, + ); + + // Default affinity is downstream. + final TextPosition positionAtBeginningOfLine = + TextPosition(offset: lineAtOffset.baseOffset); + + final Rect localLineBoundaries = Rect.fromPoints( + renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter, + renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter, + ); + final RenderBox? overlay = Overlay.of(context, rootOverlay: true) + .context + .findRenderObject() as RenderBox?; + final Matrix4 transformToOverlay = renderEditable.getTransformTo(overlay); + final Rect overlayLineBoundaries = MatrixUtils.transformRect( + transformToOverlay, + localLineBoundaries, + ); + + final Rect localCaretRect = + renderEditable.getLocalRectForCaret(currentTextPosition); + final Rect overlayCaretRect = + MatrixUtils.transformRect(transformToOverlay, localCaretRect); + + final Offset overlayGesturePosition = + overlay?.globalToLocal(globalGesturePosition) ?? globalGesturePosition; + + return MagnifierInfo( + fieldBounds: MatrixUtils.transformRect( + transformToOverlay, renderEditable.paintBounds), + globalGesturePosition: overlayGesturePosition, + caretRect: overlayCaretRect, + currentLineBoundaries: overlayLineBoundaries, + ); + } + + // The contact position of the gesture at the current end handle location, in + // global coordinates. Updated when the handle moves. + late double _endHandleDragPosition; + + // The distance from _endHandleDragPosition to the center of the line that it + // corresponds to, in global coordinates. + late double _endHandleDragTarget; + + // The initial selection when a selection handle drag has started. + TextSelection? _dragStartSelection; + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + if (!renderObject.attached) { + return; + } + + _endHandleDragPosition = details.globalPosition.dy; + + // Use local coordinates when dealing with line height. because in case of a + // scale transformation, the line height will also be scaled. + final double centerOfLineLocal = + _selectionOverlay.selectionEndpoints.last.point.dy - + renderObject.preferredLineHeight / 2; + final double centerOfLineGlobal = + renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy; + _endHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy; + // Instead of finding the TextPosition at the handle's location directly, + // use the vertical center of the line that it points to. This is because + // selection handles typically hang above or below the line that they point + // to. + final TextPosition position = renderObject.getPositionForPoint( + Offset(details.globalPosition.dx, centerOfLineGlobal), + ); + _dragStartSelection ??= _selection; + + _selectionOverlay.showMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + } + + /// Given a handle position and drag position, returns the position of handle + /// after the drag. + /// + /// The handle jumps instantly between lines when the drag reaches a full + /// line's height away from the original handle position. In other words, the + /// line jump happens when the contact point would be located at the same + /// place on the handle at the new line as when the gesture started, for both + /// directions. + /// + /// This is not the same as just maintaining an offset from the target and the + /// contact point. There is no point at which moving the drag up and down a + /// small sub-line-height distance will cause the cursor to jump up and down + /// between lines. The drag distance must be a full line height for the cursor + /// to change lines, for both directions. + /// + /// Both parameters must be in local coordinates because the untransformed + /// line height is used, and the return value is in local coordinates as well. + double _getHandleDy(double dragDy, double handleDy) { + final double distanceDragged = dragDy - handleDy; + final int dragDirection = distanceDragged < 0.0 ? -1 : 1; + final int linesDragged = dragDirection * + (distanceDragged.abs() / renderObject.preferredLineHeight).floor(); + return handleDy + linesDragged * renderObject.preferredLineHeight; + } + + void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { + if (!renderObject.attached) { + return; + } + assert(_dragStartSelection != null); + + // This is NOT the same as details.localPosition. That is relative to the + // selection handle, whereas this is relative to the RenderEditable. + final Offset localPosition = + renderObject.globalToLocal(details.globalPosition); + + final double nextEndHandleDragPositionLocal = _getHandleDy( + localPosition.dy, + renderObject.globalToLocal(Offset(0.0, _endHandleDragPosition)).dy, + ); + _endHandleDragPosition = renderObject + .localToGlobal(Offset(0.0, nextEndHandleDragPositionLocal)) + .dy; + + final Offset handleTargetGlobal = Offset( + details.globalPosition.dx, + _endHandleDragPosition + _endHandleDragTarget, + ); + + TextPosition position = + renderObject.getPositionForPoint(handleTargetGlobal); + + // bggRGjQaUbCoE right drag + position = controller.dragOffset(position); + + if (_dragStartSelection!.isCollapsed) { + _selectionOverlay.updateMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + + final TextSelection currentSelection = + TextSelection.fromPosition(position); + _handleSelectionHandleChanged(currentSelection); + return; + } + + final TextSelection newSelection; + switch (defaultTargetPlatform) { + // On Apple platforms, dragging the base handle makes it the extent. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized + // always returns true for a TextSelection. + final bool dragStartSelectionNormalized = + _dragStartSelection!.extentOffset >= + _dragStartSelection!.baseOffset; + newSelection = TextSelection( + baseOffset: dragStartSelectionNormalized + ? _dragStartSelection!.baseOffset + : _dragStartSelection!.extentOffset, + extentOffset: position.offset, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + newSelection = TextSelection( + baseOffset: _selection.baseOffset, + extentOffset: position.offset, + ); + if (newSelection.baseOffset >= newSelection.extentOffset) { + return; // Don't allow order swapping. + } + } + + _handleSelectionHandleChanged(newSelection); + + _selectionOverlay.updateMagnifier( + _buildMagnifier( + currentTextPosition: newSelection.extent, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + } + + // The contact position of the gesture at the current start handle location, + // in global coordinates. Updated when the handle moves. + late double _startHandleDragPosition; + + // The distance from _startHandleDragPosition to the center of the line that + // it corresponds to, in global coordinates. + late double _startHandleDragTarget; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + if (!renderObject.attached) { + return; + } + + _startHandleDragPosition = details.globalPosition.dy; + + // Use local coordinates when dealing with line height. because in case of a + // scale transformation, the line height will also be scaled. + final double centerOfLineLocal = + _selectionOverlay.selectionEndpoints.first.point.dy - + renderObject.preferredLineHeight / 2; + final double centerOfLineGlobal = + renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy; + _startHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy; + // Instead of finding the TextPosition at the handle's location directly, + // use the vertical center of the line that it points to. This is because + // selection handles typically hang above or below the line that they point + // to. + final TextPosition position = renderObject.getPositionForPoint( + Offset(details.globalPosition.dx, centerOfLineGlobal), + ); + _dragStartSelection ??= _selection; + + _selectionOverlay.showMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + if (!renderObject.attached) { + return; + } + assert(_dragStartSelection != null); + + // This is NOT the same as details.localPosition. That is relative to the + // selection handle, whereas this is relative to the RenderEditable. + final Offset localPosition = + renderObject.globalToLocal(details.globalPosition); + final double nextStartHandleDragPositionLocal = _getHandleDy( + localPosition.dy, + renderObject.globalToLocal(Offset(0.0, _startHandleDragPosition)).dy, + ); + _startHandleDragPosition = renderObject + .localToGlobal(Offset(0.0, nextStartHandleDragPositionLocal)) + .dy; + final Offset handleTargetGlobal = Offset( + details.globalPosition.dx, + _startHandleDragPosition + _startHandleDragTarget, + ); + TextPosition position = + renderObject.getPositionForPoint(handleTargetGlobal); + + // bggRGjQaUbCoE single drag, left drag + position = controller.dragOffset(position); + + if (_dragStartSelection!.isCollapsed) { + _selectionOverlay.updateMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + + final TextSelection currentSelection = + TextSelection.fromPosition(position); + _handleSelectionHandleChanged(currentSelection); + return; + } + + final TextSelection newSelection; + switch (defaultTargetPlatform) { + // On Apple platforms, dragging the base handle makes it the extent. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized + // always returns true for a TextSelection. + final bool dragStartSelectionNormalized = + _dragStartSelection!.extentOffset >= + _dragStartSelection!.baseOffset; + newSelection = TextSelection( + baseOffset: dragStartSelectionNormalized + ? _dragStartSelection!.extentOffset + : _dragStartSelection!.baseOffset, + extentOffset: position.offset, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + newSelection = TextSelection( + baseOffset: position.offset, + extentOffset: _selection.extentOffset, + ); + if (newSelection.baseOffset >= newSelection.extentOffset) { + return; // Don't allow order swapping. + } + } + + _selectionOverlay.updateMagnifier( + _buildMagnifier( + currentTextPosition: + newSelection.extent.offset < newSelection.base.offset + ? newSelection.extent + : newSelection.base, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + + _handleSelectionHandleChanged(newSelection); + } + + void _handleAnyDragEnd(DragEndDetails details) { + if (!context.mounted) { + return; + } + _dragStartSelection = null; + if (selectionControls is! TextSelectionHandleControls) { + _selectionOverlay.hideMagnifier(); + if (!_selection.isCollapsed) { + _selectionOverlay.showToolbar(); + } + return; + } + _selectionOverlay.hideMagnifier(); + if (!_selection.isCollapsed) { + _selectionOverlay.showToolbar( + context: context, contextMenuBuilder: contextMenuBuilder); + } + } + + void _handleSelectionHandleChanged(TextSelection newSelection) { + selectionDelegate.userUpdateTextEditingValue( + _value.copyWith(selection: newSelection), + SelectionChangedCause.drag, + ); + } + + TextSelectionHandleType _chooseType( + TextDirection textDirection, + TextSelectionHandleType ltrType, + TextSelectionHandleType rtlType, + ) { + if (_selection.isCollapsed) { + return TextSelectionHandleType.collapsed; + } + + return switch (textDirection) { + TextDirection.ltr => ltrType, + TextDirection.rtl => rtlType, + }; + } +} + +/// An object that manages a pair of selection handles and a toolbar. +/// +/// The selection handles are displayed in the [Overlay] that most closely +/// encloses the given [BuildContext]. +class SelectionOverlay { + /// Creates an object that manages overlay entries for selection handles. + /// + /// The [context] must have an [Overlay] as an ancestor. + SelectionOverlay({ + required this.context, + this.debugRequiredFor, + required TextSelectionHandleType startHandleType, + required double lineHeightAtStart, + this.startHandlesVisible, + this.onStartHandleDragStart, + this.onStartHandleDragUpdate, + this.onStartHandleDragEnd, + required TextSelectionHandleType endHandleType, + required double lineHeightAtEnd, + this.endHandlesVisible, + this.onEndHandleDragStart, + this.onEndHandleDragUpdate, + this.onEndHandleDragEnd, + this.toolbarVisible, + required List selectionEndpoints, + required this.selectionControls, + @Deprecated( + 'Use `contextMenuBuilder` in `showToolbar` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + required this.selectionDelegate, + required this.clipboardStatus, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + required this.toolbarLayerLink, + this.dragStartBehavior = DragStartBehavior.start, + this.onSelectionHandleTapped, + @Deprecated( + 'Use `contextMenuBuilder` in `showToolbar` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + Offset? toolbarLocation, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, + }) : _startHandleType = startHandleType, + _lineHeightAtStart = lineHeightAtStart, + _endHandleType = endHandleType, + _lineHeightAtEnd = lineHeightAtEnd, + _selectionEndpoints = selectionEndpoints, + _toolbarLocation = toolbarLocation, + assert(debugCheckHasOverlay(context)) { + assert(debugMaybeDispatchCreated('widgets', 'SelectionOverlay', this)); + } + + /// {@macro flutter.widgets.SelectionOverlay.context} + final BuildContext context; + + final ValueNotifier _magnifierInfo = + ValueNotifier( + MagnifierInfo.empty, + ); + + // [MagnifierController.show] and [MagnifierController.hide] should not be + // called directly, except from inside [showMagnifier] and [hideMagnifier]. If + // it is desired to show or hide the magnifier, call [showMagnifier] or + // [hideMagnifier]. This is because the magnifier needs to orchestrate with + // other properties in [SelectionOverlay]. + final MagnifierController _magnifierController = MagnifierController(); + + /// The configuration for the magnifier. + /// + /// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration magnifierConfiguration; + + /// {@template flutter.widgets.SelectionOverlay.toolbarIsVisible} + /// Whether the toolbar is currently visible. + /// + /// Includes both the text selection toolbar and the spell check menu. + /// {@endtemplate} + bool get toolbarIsVisible { + return selectionControls is TextSelectionHandleControls + ? _contextMenuController.isShown || _spellCheckToolbarController.isShown + : _toolbar != null || _spellCheckToolbarController.isShown; + } + + /// {@template flutter.widgets.SelectionOverlay.showMagnifier} + /// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier] + /// was called. This is safe to call on platforms not mobile, since + /// a magnifierBuilder will not be provided, or the magnifierBuilder will return null + /// on platforms not mobile. + /// + /// This is NOT the source of truth for if the magnifier is up or not, + /// since magnifiers may hide themselves. If this info is needed, check + /// [MagnifierController.shown]. + /// {@endtemplate} + void showMagnifier(MagnifierInfo initialMagnifierInfo) { + if (toolbarIsVisible) { + hideToolbar(); + } + + // Start from empty, so we don't utilize any remnant values. + _magnifierInfo.value = initialMagnifierInfo; + + // Pre-build the magnifiers so we can tell if we've built something + // or not. If we don't build a magnifiers, then we should not + // insert anything in the overlay. + final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder( + context, + _magnifierController, + _magnifierInfo, + ); + + if (builtMagnifier == null) { + return; + } + + _magnifierController.show( + context: context, + below: magnifierConfiguration.shouldDisplayHandlesInMagnifier + ? null + : _handles?.start, + builder: (_) => builtMagnifier, + ); + } + + /// {@template flutter.widgets.SelectionOverlay.hideMagnifier} + /// Hide the current magnifier. + /// + /// This does nothing if there is no magnifier. + /// {@endtemplate} + void hideMagnifier() { + // This cannot be a check on `MagnifierController.shown`, since + // it's possible that the magnifier is still in the overlay, but + // not shown in cases where the magnifier hides itself. + if (_magnifierController.overlayEntry == null) { + return; + } + + _magnifierController.hide(); + } + + /// The type of start selection handle. + /// + /// Changing the value while the handles are visible causes them to rebuild. + TextSelectionHandleType get startHandleType => _startHandleType; + TextSelectionHandleType _startHandleType; + set startHandleType(TextSelectionHandleType value) { + if (_startHandleType == value) { + return; + } + _startHandleType = value; + markNeedsBuild(); + } + + /// The line height at the selection start. + /// + /// This value is used for calculating the size of the start selection handle. + /// + /// Changing the value while the handles are visible causes them to rebuild. + double get lineHeightAtStart => _lineHeightAtStart; + double _lineHeightAtStart; + set lineHeightAtStart(double value) { + if (_lineHeightAtStart == value) { + return; + } + _lineHeightAtStart = value; + markNeedsBuild(); + } + + bool _isDraggingStartHandle = false; + + /// Whether the start handle is visible. + /// + /// If the value changes, the start handle uses [FadeTransition] to transition + /// itself on and off the screen. + /// + /// If this is null, the start selection handle will always be visible. + final ValueListenable? startHandlesVisible; + + /// Called when the users start dragging the start selection handles. + final ValueChanged? onStartHandleDragStart; + + void _handleStartHandleDragStart(DragStartDetails details) { + assert(!_isDraggingStartHandle); + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + _isDraggingStartHandle = false; + return; + } + _isDraggingStartHandle = details.kind == PointerDeviceKind.touch; + onStartHandleDragStart?.call(details); + } + + void _handleStartHandleDragUpdate(DragUpdateDetails details) { + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + _isDraggingStartHandle = false; + return; + } + onStartHandleDragUpdate?.call(details); + } + + /// Called when the users drag the start selection handles to new locations. + final ValueChanged? onStartHandleDragUpdate; + + /// Called when the users lift their fingers after dragging the start selection + /// handles. + final ValueChanged? onStartHandleDragEnd; + + void _handleStartHandleDragEnd(DragEndDetails details) { + _isDraggingStartHandle = false; + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + return; + } + onStartHandleDragEnd?.call(details); + } + + /// The type of end selection handle. + /// + /// Changing the value while the handles are visible causes them to rebuild. + TextSelectionHandleType get endHandleType => _endHandleType; + TextSelectionHandleType _endHandleType; + set endHandleType(TextSelectionHandleType value) { + if (_endHandleType == value) { + return; + } + _endHandleType = value; + markNeedsBuild(); + } + + /// The line height at the selection end. + /// + /// This value is used for calculating the size of the end selection handle. + /// + /// Changing the value while the handles are visible causes them to rebuild. + double get lineHeightAtEnd => _lineHeightAtEnd; + double _lineHeightAtEnd; + set lineHeightAtEnd(double value) { + if (_lineHeightAtEnd == value) { + return; + } + _lineHeightAtEnd = value; + markNeedsBuild(); + } + + bool _isDraggingEndHandle = false; + + /// Whether the end handle is visible. + /// + /// If the value changes, the end handle uses [FadeTransition] to transition + /// itself on and off the screen. + /// + /// If this is null, the end selection handle will always be visible. + final ValueListenable? endHandlesVisible; + + /// Called when the users start dragging the end selection handles. + final ValueChanged? onEndHandleDragStart; + + void _handleEndHandleDragStart(DragStartDetails details) { + assert(!_isDraggingEndHandle); + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + _isDraggingEndHandle = false; + return; + } + _isDraggingEndHandle = details.kind == PointerDeviceKind.touch; + onEndHandleDragStart?.call(details); + } + + void _handleEndHandleDragUpdate(DragUpdateDetails details) { + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + _isDraggingEndHandle = false; + return; + } + onEndHandleDragUpdate?.call(details); + } + + /// Called when the users drag the end selection handles to new locations. + final ValueChanged? onEndHandleDragUpdate; + + /// Called when the users lift their fingers after dragging the end selection + /// handles. + final ValueChanged? onEndHandleDragEnd; + + void _handleEndHandleDragEnd(DragEndDetails details) { + _isDraggingEndHandle = false; + // Calling OverlayEntry.remove may not happen until the following frame, so + // it's possible for the handles to receive a gesture after calling remove. + if (_handles == null) { + return; + } + onEndHandleDragEnd?.call(details); + } + + /// Whether the toolbar is visible. + /// + /// If the value changes, the toolbar uses [FadeTransition] to transition + /// itself on and off the screen. + /// + /// If this is null the toolbar will always be visible. + final ValueListenable? toolbarVisible; + + /// The text selection positions of selection start and end. + List get selectionEndpoints => _selectionEndpoints; + List _selectionEndpoints; + set selectionEndpoints(List value) { + if (!listEquals(_selectionEndpoints, value)) { + markNeedsBuild(); + if (_isDraggingEndHandle || _isDraggingStartHandle) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + HapticFeedback.selectionClick(); + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + } + _selectionEndpoints = value; + } + + /// Debugging information for explaining why the [Overlay] is required. + final Widget? debugRequiredFor; + + /// The object supplied to the [CompositedTransformTarget] that wraps the text + /// field. + final LayerLink toolbarLayerLink; + + /// The objects supplied to the [CompositedTransformTarget] that wraps the + /// location of start selection handle. + final LayerLink startHandleLayerLink; + + /// The objects supplied to the [CompositedTransformTarget] that wraps the + /// location of end selection handle. + final LayerLink endHandleLayerLink; + + /// {@template flutter.widgets.SelectionOverlay.selectionControls} + /// Builds text selection handles and toolbar. + /// {@endtemplate} + final TextSelectionControls? selectionControls; + + /// {@template flutter.widgets.SelectionOverlay.selectionDelegate} + /// The delegate for manipulating the current selection in the owning + /// text field. + /// {@endtemplate} + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final TextSelectionDelegate? selectionDelegate; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], handle drag behavior will + /// begin at the position where the drag gesture won the arena. If set to + /// [DragStartBehavior.down] it will begin at the position where a down + /// event is first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.SelectionOverlay.onSelectionHandleTapped} + /// A callback that's optionally invoked when a selection handle is tapped. + /// + /// The [TextSelectionControls.buildHandle] implementation the text field + /// uses decides where the handle's tap "hotspot" is, or whether the + /// selection handle supports tap gestures at all. For instance, + /// [MaterialTextSelectionControls] calls [onSelectionHandleTapped] when the + /// selection handle's "knob" is tapped, while + /// [CupertinoTextSelectionControls] builds a handle that's not sufficiently + /// large for tapping (as it's not meant to be tapped) so it does not call + /// [onSelectionHandleTapped] even when tapped. + /// {@endtemplate} + // See https://github.com/flutter/flutter/issues/39376#issuecomment-848406415 + // for provenance. + final VoidCallback? onSelectionHandleTapped; + + /// Maintains the status of the clipboard for determining if its contents can + /// be pasted or not. + /// + /// Useful because the actual value of the clipboard can only be checked + /// asynchronously (see [Clipboard.getData]). + final ClipboardStatusNotifier? clipboardStatus; + + /// The location of where the toolbar should be drawn in relative to the + /// location of [toolbarLayerLink]. + /// + /// If this is null, the toolbar is drawn based on [selectionEndpoints] and + /// the rect of render object of [context]. + /// + /// This is useful for displaying toolbars at the mouse right-click locations + /// in desktop devices. + @Deprecated( + 'Use the `contextMenuBuilder` parameter in `showToolbar` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + Offset? get toolbarLocation => _toolbarLocation; + Offset? _toolbarLocation; + set toolbarLocation(Offset? value) { + if (_toolbarLocation == value) { + return; + } + _toolbarLocation = value; + markNeedsBuild(); + } + + /// Controls the fade-in and fade-out animations for the toolbar and handles. + static const Duration fadeDuration = Duration(milliseconds: 150); + + /// A pair of handles. If this is non-null, there are always 2, though the + /// second is hidden when the selection is collapsed. + ({OverlayEntry start, OverlayEntry end})? _handles; + + /// A copy/paste toolbar. + OverlayEntry? _toolbar; + + // Manages the context menu. Not necessarily visible when non-null. + final ContextMenuController _contextMenuController = ContextMenuController(); + + final ContextMenuController _spellCheckToolbarController = + ContextMenuController(); + + /// {@template flutter.widgets.SelectionOverlay.showHandles} + /// Builds the handles by inserting them into the [context]'s overlay. + /// {@endtemplate} + void showHandles() { + if (_handles != null) { + return; + } + + final OverlayState overlay = Overlay.of( + context, + rootOverlay: true, + debugRequiredFor: debugRequiredFor, + ); + + final CapturedThemes capturedThemes = InheritedTheme.capture( + from: context, + to: overlay.context, + ); + + _handles = ( + start: OverlayEntry( + builder: (BuildContext context) { + return capturedThemes.wrap(_buildStartHandle(context)); + }, + ), + end: OverlayEntry( + builder: (BuildContext context) { + return capturedThemes.wrap(_buildEndHandle(context)); + }, + ), + ); + overlay.insertAll([_handles!.start, _handles!.end]); + } + + /// {@template flutter.widgets.SelectionOverlay.hideHandles} + /// Destroys the handles by removing them from overlay. + /// {@endtemplate} + void hideHandles() { + if (_handles != null) { + _handles!.start.remove(); + _handles!.start.dispose(); + _handles!.end.remove(); + _handles!.end.dispose(); + _handles = null; + } + } + + /// {@template flutter.widgets.SelectionOverlay.showToolbar} + /// Shows the toolbar by inserting it into the [context]'s overlay. + /// {@endtemplate} + void showToolbar({BuildContext? context, WidgetBuilder? contextMenuBuilder}) { + if (contextMenuBuilder == null) { + if (_toolbar != null) { + return; + } + _toolbar = OverlayEntry(builder: _buildToolbar); + Overlay.of( + this.context, + rootOverlay: true, + debugRequiredFor: debugRequiredFor, + ).insert(_toolbar!); + return; + } + + if (context == null) { + return; + } + + final RenderBox renderBox = context.findRenderObject()! as RenderBox; + _contextMenuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return _SelectionToolbarWrapper( + visibility: toolbarVisible, + layerLink: toolbarLayerLink, + offset: -renderBox.localToGlobal(Offset.zero), + child: contextMenuBuilder(context), + ); + }, + ); + } + + /// Shows toolbar with spell check suggestions of misspelled words that are + /// available for click-and-replace. + void showSpellCheckSuggestionsToolbar( + {BuildContext? context, required WidgetBuilder builder}) { + if (context == null) { + return; + } + + final RenderBox renderBox = context.findRenderObject()! as RenderBox; + _spellCheckToolbarController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return _SelectionToolbarWrapper( + layerLink: toolbarLayerLink, + offset: -renderBox.localToGlobal(Offset.zero), + child: builder(context), + ); + }, + ); + } + + bool _buildScheduled = false; + + /// Rebuilds the selection toolbar or handles if they are present. + void markNeedsBuild() { + if (_handles == null && _toolbar == null) { + return; + } + // If we are in build state, it will be too late to update visibility. + // We will need to schedule the build in next frame. + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + if (_buildScheduled) { + return; + } + _buildScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + _buildScheduled = false; + _handles?.start.markNeedsBuild(); + _handles?.end.markNeedsBuild(); + _toolbar?.markNeedsBuild(); + if (_contextMenuController.isShown) { + _contextMenuController.markNeedsBuild(); + } else if (_spellCheckToolbarController.isShown) { + _spellCheckToolbarController.markNeedsBuild(); + } + }, debugLabel: 'SelectionOverlay.markNeedsBuild'); + } else { + if (_handles != null) { + _handles!.start.markNeedsBuild(); + _handles!.end.markNeedsBuild(); + } + _toolbar?.markNeedsBuild(); + if (_contextMenuController.isShown) { + _contextMenuController.markNeedsBuild(); + } else if (_spellCheckToolbarController.isShown) { + _spellCheckToolbarController.markNeedsBuild(); + } + } + } + + /// {@template flutter.widgets.SelectionOverlay.hide} + /// Hides the entire overlay including the toolbar and the handles. + /// {@endtemplate} + void hide() { + _magnifierController.hide(); + hideHandles(); + if (_toolbar != null || + _contextMenuController.isShown || + _spellCheckToolbarController.isShown) { + hideToolbar(); + } + } + + /// {@template flutter.widgets.SelectionOverlay.hideToolbar} + /// Hides the toolbar part of the overlay. + /// + /// To hide the whole overlay, see [hide]. + /// {@endtemplate} + void hideToolbar() { + _contextMenuController.remove(); + _spellCheckToolbarController.remove(); + if (_toolbar == null) { + return; + } + _toolbar?.remove(); + _toolbar?.dispose(); + _toolbar = null; + } + + /// {@template flutter.widgets.SelectionOverlay.dispose} + /// Disposes this object and release resources. + /// {@endtemplate} + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + hide(); + _magnifierInfo.dispose(); + } + + Widget _buildStartHandle(BuildContext context) { + final Widget handle; + final TextSelectionControls? selectionControls = this.selectionControls; + if (selectionControls == null || + (_startHandleType == TextSelectionHandleType.collapsed && + _isDraggingEndHandle)) { + // Hide the start handle when dragging the end handle and collapsing + // the selection. + handle = const SizedBox.shrink(); + } else { + handle = _SelectionHandleOverlay( + type: _startHandleType, + handleLayerLink: startHandleLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragStart: _handleStartHandleDragStart, + onSelectionHandleDragUpdate: _handleStartHandleDragUpdate, + onSelectionHandleDragEnd: _handleStartHandleDragEnd, + selectionControls: selectionControls, + visibility: startHandlesVisible, + preferredLineHeight: _lineHeightAtStart, + dragStartBehavior: dragStartBehavior, + ); + } + return TextFieldTapRegion(child: ExcludeSemantics(child: handle)); + } + + Widget _buildEndHandle(BuildContext context) { + final Widget handle; + final TextSelectionControls? selectionControls = this.selectionControls; + if (selectionControls == null || + (_endHandleType == TextSelectionHandleType.collapsed && + _isDraggingStartHandle) || + (_endHandleType == TextSelectionHandleType.collapsed && + !_isDraggingStartHandle && + !_isDraggingEndHandle)) { + // Hide the end handle when dragging the start handle and collapsing the selection + // or when the selection is collapsed and no handle is being dragged. + handle = const SizedBox.shrink(); + } else { + handle = _SelectionHandleOverlay( + type: _endHandleType, + handleLayerLink: endHandleLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragStart: _handleEndHandleDragStart, + onSelectionHandleDragUpdate: _handleEndHandleDragUpdate, + onSelectionHandleDragEnd: _handleEndHandleDragEnd, + selectionControls: selectionControls, + visibility: endHandlesVisible, + preferredLineHeight: _lineHeightAtEnd, + dragStartBehavior: dragStartBehavior, + ); + } + return TextFieldTapRegion(child: ExcludeSemantics(child: handle)); + } + + // Build the toolbar via TextSelectionControls. + Widget _buildToolbar(BuildContext context) { + if (selectionControls == null) { + return const SizedBox.shrink(); + } + assert( + selectionDelegate != null, + 'If not using contextMenuBuilder, must pass selectionDelegate.', + ); + + final RenderBox renderBox = this.context.findRenderObject()! as RenderBox; + + final Rect editingRegion = Rect.fromPoints( + renderBox.localToGlobal(Offset.zero), + renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)), + ); + + final bool isMultiline = + selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy > + lineHeightAtEnd / 2; + + // If the selected text spans more than 1 line, horizontally center the toolbar. + // Derived from both iOS and Android. + final double midX = isMultiline + ? editingRegion.width / 2 + : (selectionEndpoints.first.point.dx + + selectionEndpoints.last.point.dx) / + 2; + + final Offset midpoint = Offset( + midX, + // The y-coordinate won't be made use of most likely. + selectionEndpoints.first.point.dy - lineHeightAtStart, + ); + + return _SelectionToolbarWrapper( + visibility: toolbarVisible, + layerLink: toolbarLayerLink, + offset: -editingRegion.topLeft, + child: Builder( + builder: (BuildContext context) { + return selectionControls!.buildToolbar( + context, + editingRegion, + lineHeightAtStart, + midpoint, + selectionEndpoints, + selectionDelegate!, + clipboardStatus, + toolbarLocation, + ); + }, + ), + ); + } + + /// {@template flutter.widgets.SelectionOverlay.updateMagnifier} + /// Update the current magnifier with new selection data, so the magnifier + /// can respond accordingly. + /// + /// If the magnifier is not shown, this still updates the magnifier position + /// because the magnifier may have hidden itself and is looking for a cue to reshow + /// itself. + /// + /// If there is no magnifier in the overlay, this does nothing. + /// {@endtemplate} + void updateMagnifier(MagnifierInfo magnifierInfo) { + if (_magnifierController.overlayEntry == null) { + return; + } + + _magnifierInfo.value = magnifierInfo; + } +} + +// TODO(justinmc): Currently this fades in but not out on all platforms. It +// should follow the correct fading behavior for the current platform, then be +// made public and de-duplicated with widgets/selectable_region.dart. +// https://github.com/flutter/flutter/issues/107732 +// Wrap the given child in the widgets common to both contextMenuBuilder and +// TextSelectionControls.buildToolbar. +class _SelectionToolbarWrapper extends StatefulWidget { + const _SelectionToolbarWrapper({ + this.visibility, + required this.layerLink, + required this.offset, + required this.child, + }); + + final Widget child; + final Offset offset; + final LayerLink layerLink; + final ValueListenable? visibility; + + @override + State<_SelectionToolbarWrapper> createState() => + _SelectionToolbarWrapperState(); +} + +class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: SelectionOverlay.fadeDuration, vsync: this); + + _toolbarVisibilityChanged(); + widget.visibility?.addListener(_toolbarVisibilityChanged); + } + + @override + void didUpdateWidget(_SelectionToolbarWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.visibility == widget.visibility) { + return; + } + oldWidget.visibility?.removeListener(_toolbarVisibilityChanged); + _toolbarVisibilityChanged(); + widget.visibility?.addListener(_toolbarVisibilityChanged); + } + + @override + void dispose() { + widget.visibility?.removeListener(_toolbarVisibilityChanged); + _controller.dispose(); + super.dispose(); + } + + void _toolbarVisibilityChanged() { + if (widget.visibility?.value ?? true) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return TextFieldTapRegion( + child: Directionality( + textDirection: Directionality.of(this.context), + child: FadeTransition( + opacity: _opacity, + child: CompositedTransformFollower( + link: widget.layerLink, + showWhenUnlinked: false, + offset: widget.offset, + child: widget.child, + ), + ), + ), + ); + } +} + +/// This widget represents a single draggable selection handle. +class _SelectionHandleOverlay extends StatefulWidget { + /// Create selection overlay. + const _SelectionHandleOverlay({ + required this.type, + required this.handleLayerLink, + this.onSelectionHandleTapped, + this.onSelectionHandleDragStart, + this.onSelectionHandleDragUpdate, + this.onSelectionHandleDragEnd, + required this.selectionControls, + this.visibility, + required this.preferredLineHeight, + this.dragStartBehavior = DragStartBehavior.start, + }); + + final LayerLink handleLayerLink; + final VoidCallback? onSelectionHandleTapped; + final ValueChanged? onSelectionHandleDragStart; + final ValueChanged? onSelectionHandleDragUpdate; + final ValueChanged? onSelectionHandleDragEnd; + final TextSelectionControls selectionControls; + final ValueListenable? visibility; + final double preferredLineHeight; + final TextSelectionHandleType type; + final DragStartBehavior dragStartBehavior; + + @override + State<_SelectionHandleOverlay> createState() => + _SelectionHandleOverlayState(); +} + +class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: SelectionOverlay.fadeDuration, vsync: this); + + _handleVisibilityChanged(); + widget.visibility?.addListener(_handleVisibilityChanged); + } + + void _handleVisibilityChanged() { + if (widget.visibility?.value ?? true) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + /// Returns the bounding [Rect] of the text selection handle in local + /// coordinates. + /// + /// When interacting with a text selection handle through a touch event, the + /// interactive area should be at least [kMinInteractiveDimension] square, + /// which this method does not consider. + Rect _getHandleRect( + TextSelectionHandleType type, double preferredLineHeight) { + final Size handleSize = + widget.selectionControls.getHandleSize(preferredLineHeight); + return Rect.fromLTWH(0.0, 0.0, handleSize.width, handleSize.height); + } + + @override + void didUpdateWidget(_SelectionHandleOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.visibility?.removeListener(_handleVisibilityChanged); + _handleVisibilityChanged(); + widget.visibility?.addListener(_handleVisibilityChanged); + } + + @override + void dispose() { + widget.visibility?.removeListener(_handleVisibilityChanged); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Rect handleRect = + _getHandleRect(widget.type, widget.preferredLineHeight); + + // Make sure the GestureDetector is big enough to be easily interactive. + final Rect interactiveRect = handleRect.expandToInclude( + Rect.fromCircle( + center: handleRect.center, radius: kMinInteractiveDimension / 2), + ); + final RelativeRect padding = RelativeRect.fromLTRB( + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + ); + + final Offset handleAnchor = widget.selectionControls.getHandleAnchor( + widget.type, + widget.preferredLineHeight, + ); + + // Make sure a drag is eagerly accepted. This is used on iOS to match the + // behavior where a drag directly on a collapse handle will always win against + // other drag gestures. + final bool eagerlyAcceptDragWhenCollapsed = + widget.type == TextSelectionHandleType.collapsed && + defaultTargetPlatform == TargetPlatform.iOS; + + return CompositedTransformFollower( + link: widget.handleLayerLink, + // Put the handle's anchor point on the leader's anchor point. + offset: -handleAnchor - Offset(padding.left, padding.top), + showWhenUnlinked: false, + child: FadeTransition( + opacity: _opacity, + child: SizedBox( + width: interactiveRect.width, + height: interactiveRect.height, + child: Align( + alignment: Alignment.topLeft, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + debugOwner: this, + // Mouse events select the text and do not drag the cursor. + supportedDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.unknown, + }, + ), + (PanGestureRecognizer instance) { + instance + ..dragStartBehavior = widget.dragStartBehavior + ..gestureSettings = eagerlyAcceptDragWhenCollapsed + ? const DeviceGestureSettings(touchSlop: 1.0) + : null + ..onStart = widget.onSelectionHandleDragStart + ..onUpdate = widget.onSelectionHandleDragUpdate + ..onEnd = widget.onSelectionHandleDragEnd; + }, + ), + }, + child: Padding( + padding: EdgeInsets.only( + left: padding.left, + top: padding.top, + right: padding.right, + bottom: padding.bottom, + ), + child: widget.selectionControls.buildHandle( + context, + widget.type, + widget.preferredLineHeight, + widget.onSelectionHandleTapped, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/http/video.dart b/lib/http/video.dart index b66f4c64..8de0c645 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -510,7 +510,7 @@ class VideoHttp { int? parent, List? pictures, bool? syncToDynamic, - Map? atNameToMid, + Map? atNameToMid, }) async { if (message == '') { return {'status': false, 'msg': '请输入评论内容'}; @@ -521,7 +521,7 @@ class VideoHttp { if (root != null && root != 0) 'root': root, if (parent != null && parent != 0) 'parent': parent, 'message': message, - if (atNameToMid != null) + if (atNameToMid?.isNotEmpty == true) 'at_name_to_mid': jsonEncode(atNameToMid), // {"name":uid} if (pictures != null) 'pictures': jsonEncode(pictures), if (syncToDynamic == true) 'sync_to_dynamic': 1, diff --git a/lib/pages/common/common_publish_page.dart b/lib/pages/common/common_publish_page.dart deleted file mode 100644 index 326ded82..00000000 --- a/lib/pages/common/common_publish_page.dart +++ /dev/null @@ -1,506 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; - -import 'package:PiliPlus/common/widgets/button/icon_button.dart'; -import 'package:PiliPlus/http/msg.dart'; -import 'package:PiliPlus/models/common/image_preview_type.dart'; -import 'package:PiliPlus/models/common/publish_panel_type.dart'; -import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; -import 'package:PiliPlus/models_new/emote/emote.dart'; -import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; -import 'package:PiliPlus/models_new/upload_bfs/data.dart'; -import 'package:PiliPlus/pages/dynamics_mention/view.dart'; -import 'package:PiliPlus/utils/extension.dart'; -import 'package:PiliPlus/utils/feed_back.dart'; -import 'package:chat_bottom_container/chat_bottom_container.dart'; -import 'package:dio/dio.dart'; -import 'package:easy_debounce/easy_throttle.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:image_cropper/image_cropper.dart'; -import 'package:image_picker/image_picker.dart'; - -abstract class CommonPublishPage extends StatefulWidget { - const CommonPublishPage({ - super.key, - this.initialValue, - this.mentions, - this.imageLengthLimit, - this.onSave, - this.autofocus = true, - }); - - final String? initialValue; - final List? mentions; - final int? imageLengthLimit; - final ValueChanged<({String text, List? mentions})>? onSave; - final bool autofocus; -} - -abstract class CommonPublishPageState - extends State with WidgetsBindingObserver { - late final focusNode = FocusNode(); - late final controller = ChatBottomPanelContainerController(); - late final editController = TextEditingController(text: widget.initialValue); - - Rx panelType = PanelType.none.obs; - late final RxBool readOnly = false.obs; - late final RxBool enablePublish = false.obs; - - late final imagePicker = ImagePicker(); - late final RxList pathList = [].obs; - int get limit => widget.imageLengthLimit ?? 9; - - List? mentions; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - - mentions = widget.mentions; - if (widget.initialValue?.trim().isNotEmpty == true) { - enablePublish.value = true; - } - - if (widget.autofocus) { - Future.delayed(const Duration(milliseconds: 300)).whenComplete(() { - if (mounted) { - focusNode.requestFocus(); - } - }); - } - } - - @override - void dispose() { - focusNode.dispose(); - editController.dispose(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - Future _requestFocus() async { - await Future.delayed(const Duration(microseconds: 200)); - focusNode.requestFocus(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - if (mounted && - widget.autofocus && - panelType.value == PanelType.keyboard) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (focusNode.hasFocus) { - focusNode.unfocus(); - _requestFocus(); - } else { - _requestFocus(); - } - }); - } - } else if (state == AppLifecycleState.paused) { - controller.keepChatPanel(); - if (focusNode.hasFocus) { - focusNode.unfocus(); - } - } - } - - void updatePanelType(PanelType type) { - final isSwitchToKeyboard = PanelType.keyboard == type; - final isSwitchToEmojiPanel = PanelType.emoji == type; - bool isUpdated = false; - switch (type) { - case PanelType.keyboard: - updateInputView(isReadOnly: false); - break; - case PanelType.emoji: - isUpdated = updateInputView(isReadOnly: true); - break; - default: - break; - } - - void updatePanelTypeFunc() { - controller.updatePanelType( - isSwitchToKeyboard - ? ChatBottomPanelType.keyboard - : ChatBottomPanelType.other, - data: type, - forceHandleFocus: isSwitchToEmojiPanel - ? ChatBottomHandleFocus.requestFocus - : ChatBottomHandleFocus.none, - ); - } - - if (isUpdated) { - // Waiting for the input view to update. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - updatePanelTypeFunc(); - }); - } else { - updatePanelTypeFunc(); - } - } - - Future hidePanel() async { - if (focusNode.hasFocus) { - await Future.delayed(const Duration(milliseconds: 100)); - focusNode.unfocus(); - } - updateInputView(isReadOnly: false); - if (ChatBottomPanelType.none == controller.currentPanelType) return; - controller.updatePanelType(ChatBottomPanelType.none); - } - - bool updateInputView({ - required bool isReadOnly, - }) { - if (readOnly.value != isReadOnly) { - readOnly.value = isReadOnly; - return true; - } - return false; - } - - Future onPublish() async { - feedBack(); - List>? pictures; - if (pathList.isNotEmpty) { - SmartDialog.showLoading(msg: '正在上传图片...'); - final cancelToken = CancelToken(); - try { - pictures = await Future.wait>( - pathList.map((path) async { - Map result = await MsgHttp.uploadBfs( - path: path, - category: 'daily', - biz: 'new_dyn', - cancelToken: cancelToken, - ); - if (!result['status']) throw HttpException(result['msg']); - UploadBfsResData data = result['data']; - return { - 'img_width': data.imageWidth, - 'img_height': data.imageHeight, - 'img_size': data.imgSize, - 'img_src': data.imageUrl, - }; - }).toList(), - eagerError: true); - SmartDialog.dismiss(); - } on HttpException catch (e) { - cancelToken.cancel(); - SmartDialog.dismiss(); - SmartDialog.showToast(e.message); - return; - } - } - onCustomPublish(message: editController.text, pictures: pictures); - } - - Future onCustomPublish({required String message, List? pictures}); - - void onChooseEmote(dynamic emote) { - if (emote is Emote) { - onInsertText(emote.text!); - } else if (emote is Emoticon) { - onInsertText(emote.emoji!); - } - } - - Widget? get customPanel => null; - - Widget buildEmojiPickerPanel() { - double height = context.isTablet ? 300 : 170; - final keyboardHeight = controller.keyboardHeight; - if (keyboardHeight != 0) { - height = max(height, keyboardHeight); - } - return SizedBox( - height: height, - child: customPanel, - ); - } - - Widget buildPanelContainer([Color? panelBgColor]) { - return ChatBottomPanelContainer( - controller: controller, - inputFocusNode: focusNode, - otherPanelWidget: (type) { - if (type == null) return const SizedBox.shrink(); - switch (type) { - case PanelType.emoji: - return buildEmojiPickerPanel(); - default: - return const SizedBox.shrink(); - } - }, - onPanelTypeChange: (panelType, data) { - // if (kDebugMode) debugPrint('panelType: $panelType'); - switch (panelType) { - case ChatBottomPanelType.none: - this.panelType.value = PanelType.none; - break; - case ChatBottomPanelType.keyboard: - this.panelType.value = PanelType.keyboard; - break; - case ChatBottomPanelType.other: - if (data == null) return; - switch (data) { - case PanelType.emoji: - this.panelType.value = PanelType.emoji; - break; - default: - this.panelType.value = PanelType.none; - break; - } - break; - } - }, - panelBgColor: panelBgColor ?? Theme.of(context).colorScheme.surface, - ); - } - - Widget buildImage(int index, double height) { - final color = - Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.5); - - void onClear() { - pathList.removeAt(index); - if (pathList.isEmpty && editController.text.trim().isEmpty) { - enablePublish.value = false; - } - } - - return Stack( - clipBehavior: Clip.none, - children: [ - GestureDetector( - onTap: () { - controller.keepChatPanel(); - context.imageView( - imgList: pathList - .map((path) => SourceModel( - url: path, - sourceType: SourceType.fileImage, - )) - .toList(), - initialPage: index, - ); - }, - onLongPress: onClear, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: Image( - height: height, - fit: BoxFit.fitHeight, - filterQuality: FilterQuality.low, - image: FileImage(File(pathList[index])), - ), - ), - ), - Positioned( - top: 34, - right: 5, - child: iconButton( - context: context, - icon: Icons.edit, - onPressed: () => onCropImage(index), - size: 24, - iconSize: 14, - bgColor: color, - ), - ), - Positioned( - top: 5, - right: 5, - child: iconButton( - context: context, - icon: Icons.clear, - onPressed: onClear, - size: 24, - iconSize: 14, - bgColor: color, - ), - ), - ], - ); - } - - Future onCropImage(int index) async { - final theme = Theme.of(context); - CroppedFile? croppedFile = await ImageCropper().cropImage( - sourcePath: pathList[index], - uiSettings: [ - AndroidUiSettings( - toolbarTitle: '裁剪', - toolbarColor: theme.colorScheme.secondaryContainer, - toolbarWidgetColor: theme.colorScheme.onSecondaryContainer, - ), - IOSUiSettings( - title: '裁剪', - ), - ], - ); - if (croppedFile != null) { - pathList[index] = croppedFile.path; - } - } - - void onPickImage([VoidCallback? callback]) { - EasyThrottle.throttle('imagePicker', const Duration(milliseconds: 500), - () async { - try { - List pickedFiles = await imagePicker.pickMultiImage( - limit: limit, - imageQuality: 100, - ); - if (pickedFiles.isNotEmpty) { - for (int i = 0; i < pickedFiles.length; i++) { - if (pathList.length == limit) { - SmartDialog.showToast('最多选择$limit张图片'); - break; - } else { - pathList.add(pickedFiles[i].path); - } - } - callback?.call(); - } - } catch (e) { - SmartDialog.showToast(e.toString()); - } - }); - } - - List>? getRichContent() { - if (mentions.isNullOrEmpty) { - return null; - } - List> content = []; - void addPlainText(String text) { - content.add({ - "raw_text": text, - "type": 1, - "biz_id": "", - }); - } - - final pattern = RegExp( - mentions!.toSet().map((e) => RegExp.escape('@${e.name!}')).join('|')); - - editController.text.splitMapJoin( - pattern, - onMatch: (Match match) { - final name = match.group(0)!; - final item = - mentions!.firstWhereOrNull((e) => e.name == name.substring(1)); - if (item != null) { - content.add({ - "raw_text": name, - "type": 2, - "biz_id": item.uid, - }); - } else { - addPlainText(name); - } - return ''; - }, - onNonMatch: (String text) { - addPlainText(text); - return ''; - }, - ); - return content; - } - - double _mentionOffset = 0; - void onMention([bool fromClick = false]) { - controller.keepChatPanel(); - DynMentionPanel.onDynMention( - context, - offset: _mentionOffset, - callback: (offset) => _mentionOffset = offset, - ).then((MentionItem? res) { - if (res != null) { - (mentions ??= []).add(res); - - String atName = '${fromClick ? '@' : ''}${res.name} '; - - onInsertText(atName); - } - }); - } - - void onInsertText(String text) { - if (text.isEmpty) { - return; - } - - enablePublish.value = true; - - final oldValue = editController.value; - final selection = oldValue.selection; - - if (selection.isValid) { - TextEditingDelta delta; - - if (selection.isCollapsed) { - delta = TextEditingDeltaInsertion( - oldText: oldValue.text, - textInserted: text, - insertionOffset: selection.start, - selection: TextSelection.collapsed( - offset: selection.start + text.length, - ), - composing: TextRange.empty, - ); - } else { - delta = TextEditingDeltaReplacement( - oldText: oldValue.text, - replacementText: text, - replacedRange: selection, - selection: TextSelection.collapsed( - offset: selection.start + text.length, - ), - composing: TextRange.empty, - ); - } - - final newValue = delta.apply(oldValue); - - if (oldValue == newValue) { - return; - } - - editController.value = newValue; - } else { - editController.value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - ); - } - - widget.onSave?.call((text: editController.text, mentions: mentions)); - } - - void onDelAtUser(String name) { - mentions!.removeFirstWhere((e) => e.name == name); - } - - void onChanged(String value) { - bool isEmpty = value.trim().isEmpty; - if (isEmpty) { - enablePublish.value = false; - mentions?.clear(); - } else { - enablePublish.value = true; - } - widget.onSave?.call((text: value, mentions: mentions)); - } -} diff --git a/lib/pages/common/publish/common_publish_page.dart b/lib/pages/common/publish/common_publish_page.dart new file mode 100644 index 00000000..63a87b24 --- /dev/null +++ b/lib/pages/common/publish/common_publish_page.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' show max; + +import 'package:PiliPlus/http/msg.dart'; +import 'package:PiliPlus/models/common/publish_panel_type.dart'; +import 'package:PiliPlus/models_new/upload_bfs/data.dart'; +import 'package:PiliPlus/utils/feed_back.dart'; +import 'package:chat_bottom_container/chat_bottom_container.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; + +abstract class CommonPublishPage extends StatefulWidget { + const CommonPublishPage({ + super.key, + this.initialValue, + this.imageLengthLimit, + this.onSave, + this.autofocus = true, + }); + + final String? initialValue; + final int? imageLengthLimit; + final ValueChanged? onSave; + final bool autofocus; +} + +abstract class CommonPublishPageState + extends State with WidgetsBindingObserver { + late final focusNode = FocusNode(); + late final controller = ChatBottomPanelContainerController(); + TextEditingController get editController; + + Rx panelType = PanelType.none.obs; + late final RxBool readOnly = false.obs; + late final RxBool enablePublish = false.obs; + + late final imagePicker = ImagePicker(); + late final RxList pathList = [].obs; + int get limit => widget.imageLengthLimit ?? 9; + + bool? hasPub; + void initPubState(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + initPubState(); + + if (widget.autofocus) { + Future.delayed(const Duration(milliseconds: 300)).whenComplete(() { + if (mounted) { + focusNode.requestFocus(); + } + }); + } + } + + @override + void dispose() { + if (hasPub != true) { + onSave(); + } + focusNode.dispose(); + editController.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _requestFocus() async { + await Future.delayed(const Duration(microseconds: 200)); + focusNode.requestFocus(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (mounted && + widget.autofocus && + panelType.value == PanelType.keyboard) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (focusNode.hasFocus) { + focusNode.unfocus(); + _requestFocus(); + } else { + _requestFocus(); + } + }); + } + } else if (state == AppLifecycleState.paused) { + controller.keepChatPanel(); + if (focusNode.hasFocus) { + focusNode.unfocus(); + } + } + } + + void updatePanelType(PanelType type) { + final isSwitchToKeyboard = PanelType.keyboard == type; + final isSwitchToEmojiPanel = PanelType.emoji == type; + bool isUpdated = false; + switch (type) { + case PanelType.keyboard: + updateInputView(isReadOnly: false); + break; + case PanelType.emoji: + isUpdated = updateInputView(isReadOnly: true); + break; + default: + break; + } + + void updatePanelTypeFunc() { + controller.updatePanelType( + isSwitchToKeyboard + ? ChatBottomPanelType.keyboard + : ChatBottomPanelType.other, + data: type, + forceHandleFocus: isSwitchToEmojiPanel + ? ChatBottomHandleFocus.requestFocus + : ChatBottomHandleFocus.none, + ); + } + + if (isUpdated) { + // Waiting for the input view to update. + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + updatePanelTypeFunc(); + }); + } else { + updatePanelTypeFunc(); + } + } + + Future hidePanel() async { + if (focusNode.hasFocus) { + await Future.delayed(const Duration(milliseconds: 100)); + focusNode.unfocus(); + } + updateInputView(isReadOnly: false); + if (ChatBottomPanelType.none == controller.currentPanelType) return; + controller.updatePanelType(ChatBottomPanelType.none); + } + + bool updateInputView({ + required bool isReadOnly, + }) { + if (readOnly.value != isReadOnly) { + readOnly.value = isReadOnly; + return true; + } + return false; + } + + Widget buildEmojiPickerPanel() { + double height = context.isTablet ? 300 : 170; + final keyboardHeight = controller.keyboardHeight; + if (keyboardHeight != 0) { + height = max(height, keyboardHeight); + } + return SizedBox( + height: height, + child: customPanel, + ); + } + + Widget buildPanelContainer([Color? panelBgColor]) { + return ChatBottomPanelContainer( + controller: controller, + inputFocusNode: focusNode, + otherPanelWidget: (type) { + if (type == null) return const SizedBox.shrink(); + switch (type) { + case PanelType.emoji: + return buildEmojiPickerPanel(); + default: + return const SizedBox.shrink(); + } + }, + onPanelTypeChange: (panelType, data) { + // if (kDebugMode) debugPrint('panelType: $panelType'); + switch (panelType) { + case ChatBottomPanelType.none: + this.panelType.value = PanelType.none; + break; + case ChatBottomPanelType.keyboard: + this.panelType.value = PanelType.keyboard; + break; + case ChatBottomPanelType.other: + if (data == null) return; + switch (data) { + case PanelType.emoji: + this.panelType.value = PanelType.emoji; + break; + default: + this.panelType.value = PanelType.none; + break; + } + break; + } + }, + panelBgColor: panelBgColor ?? Theme.of(context).colorScheme.surface, + ); + } + + Future onPublish() async { + feedBack(); + List>? pictures; + if (pathList.isNotEmpty) { + SmartDialog.showLoading(msg: '正在上传图片...'); + final cancelToken = CancelToken(); + try { + pictures = await Future.wait>( + pathList.map((path) async { + Map result = await MsgHttp.uploadBfs( + path: path, + category: 'daily', + biz: 'new_dyn', + cancelToken: cancelToken, + ); + if (!result['status']) throw HttpException(result['msg']); + UploadBfsResData data = result['data']; + return { + 'img_width': data.imageWidth, + 'img_height': data.imageHeight, + 'img_size': data.imgSize, + 'img_src': data.imageUrl, + }; + }).toList(), + eagerError: true); + SmartDialog.dismiss(); + } on HttpException catch (e) { + cancelToken.cancel(); + SmartDialog.dismiss(); + SmartDialog.showToast(e.message); + return; + } + } + onCustomPublish(pictures: pictures); + } + + Future onCustomPublish({List? pictures}); + + Widget? get customPanel => null; + + void onChanged(String value) { + enablePublish.value = value.trim().isNotEmpty; + } + + void onSave() {} +} diff --git a/lib/pages/common/publish/common_rich_text_pub_page.dart b/lib/pages/common/publish/common_rich_text_pub_page.dart new file mode 100644 index 00000000..dcc55ebf --- /dev/null +++ b/lib/pages/common/publish/common_rich_text_pub_page.dart @@ -0,0 +1,342 @@ +import 'dart:io'; + +import 'package:PiliPlus/common/widgets/button/icon_button.dart'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; +import 'package:PiliPlus/models/common/image_preview_type.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; +import 'package:PiliPlus/models_new/emote/emote.dart' as e; +import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; +import 'package:PiliPlus/pages/common/publish/common_publish_page.dart'; +import 'package:PiliPlus/pages/dynamics_mention/view.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; + +abstract class CommonRichTextPubPage + extends CommonPublishPage> { + const CommonRichTextPubPage({ + super.key, + this.items, + super.onSave, + super.autofocus, + super.imageLengthLimit, + }); + + final List? items; +} + +abstract class CommonRichTextPubPageState + extends CommonPublishPageState { + bool? hasPub; + + @override + late final RichTextEditingController editController = + RichTextEditingController( + items: widget.items, + onMention: onMention, + ); + + @override + void initPubState() { + if (editController.rawText.trim().isNotEmpty) { + enablePublish.value = true; + } + } + + @override + void didChangeDependencies() { + editController.richStyle = null; + super.didChangeDependencies(); + } + + Widget buildImage(int index, double height) { + final color = + Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.5); + + void onClear() { + pathList.removeAt(index); + if (pathList.isEmpty && editController.rawText.trim().isEmpty) { + enablePublish.value = false; + } + } + + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () { + controller.keepChatPanel(); + context.imageView( + imgList: pathList + .map((path) => SourceModel( + url: path, + sourceType: SourceType.fileImage, + )) + .toList(), + initialPage: index, + ); + }, + onLongPress: onClear, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Image( + height: height, + fit: BoxFit.fitHeight, + filterQuality: FilterQuality.low, + image: FileImage(File(pathList[index])), + ), + ), + ), + Positioned( + top: 34, + right: 5, + child: iconButton( + context: context, + icon: Icons.edit, + onPressed: () => onCropImage(index), + size: 24, + iconSize: 14, + bgColor: color, + ), + ), + Positioned( + top: 5, + right: 5, + child: iconButton( + context: context, + icon: Icons.clear, + onPressed: onClear, + size: 24, + iconSize: 14, + bgColor: color, + ), + ), + ], + ); + } + + Future onCropImage(int index) async { + final theme = Theme.of(context); + CroppedFile? croppedFile = await ImageCropper().cropImage( + sourcePath: pathList[index], + uiSettings: [ + AndroidUiSettings( + toolbarTitle: '裁剪', + toolbarColor: theme.colorScheme.secondaryContainer, + toolbarWidgetColor: theme.colorScheme.onSecondaryContainer, + ), + IOSUiSettings( + title: '裁剪', + ), + ], + ); + if (croppedFile != null) { + pathList[index] = croppedFile.path; + } + } + + void onPickImage([VoidCallback? callback]) { + EasyThrottle.throttle('imagePicker', const Duration(milliseconds: 500), + () async { + try { + List pickedFiles = await imagePicker.pickMultiImage( + limit: limit, + imageQuality: 100, + ); + if (pickedFiles.isNotEmpty) { + for (int i = 0; i < pickedFiles.length; i++) { + if (pathList.length == limit) { + SmartDialog.showToast('最多选择$limit张图片'); + break; + } else { + pathList.add(pickedFiles[i].path); + } + } + callback?.call(); + } + } catch (e) { + SmartDialog.showToast(e.toString()); + } + }); + } + + void onChooseEmote(dynamic emote, double? width, double? height) { + if (emote is e.Emote) { + final isTextEmote = width == null; + onInsertText( + isTextEmote ? emote.text! : '\uFFFC', + RichTextType.emoji, + rawText: emote.text!, + emote: isTextEmote + ? null + : Emote( + url: emote.url!, + width: width, + height: height, + ), + ); + } else if (emote is Emoticon) { + onInsertText( + '\uFFFC', + RichTextType.emoji, + rawText: emote.emoji!, + emote: Emote( + url: emote.url!, + width: width!, + height: height, + ), + ); + } + } + + List>? getRichContent() { + if (editController.items.isEmpty) return null; + return editController.items.map((e) { + return switch (e.type) { + RichTextType.text || RichTextType.composing => { + "raw_text": e.text, + "type": 1, + "biz_id": "", + }, + RichTextType.at => { + "raw_text": '@${e.rawText}', + "type": 2, + "biz_id": e.uid, + }, + RichTextType.emoji => { + "raw_text": e.rawText, + "type": 9, + "biz_id": "", + }, + }; + }).toList(); + } + + double _mentionOffset = 0; + void onMention([bool fromClick = false]) { + controller.keepChatPanel(); + DynMentionPanel.onDynMention( + context, + offset: _mentionOffset, + callback: (offset) => _mentionOffset = offset, + ).then((MentionItem? res) { + if (res != null) { + onInsertText( + '@${res.name} ', + RichTextType.at, + rawText: res.name, + uid: res.uid, + fromClick: fromClick, + ); + } + }); + } + + void onInsertText( + String text, + RichTextType type, { + String? rawText, + Emote? emote, + String? uid, + bool? fromClick, + }) { + if (text.isEmpty) { + return; + } + + enablePublish.value = true; + + var oldValue = editController.value; + final selection = oldValue.selection; + + if (selection.isValid) { + TextEditingDelta delta; + + if (selection.isCollapsed) { + if (type == RichTextType.at && fromClick == false) { + delta = RichTextEditingDeltaReplacement( + oldText: oldValue.text, + replacementText: text, + replacedRange: + TextRange(start: selection.start - 1, end: selection.end), + selection: TextSelection.collapsed( + offset: selection.start - 1 + text.length, + ), + composing: TextRange.empty, + rawText: rawText, + type: type, + emote: emote, + uid: uid, + ); + } else { + delta = RichTextEditingDeltaInsertion( + oldText: oldValue.text, + textInserted: text, + insertionOffset: selection.start, + selection: TextSelection.collapsed( + offset: selection.start + text.length, + ), + composing: TextRange.empty, + rawText: rawText, + type: type, + emote: emote, + uid: uid, + ); + } + } else { + delta = RichTextEditingDeltaReplacement( + oldText: oldValue.text, + replacementText: text, + replacedRange: selection, + selection: TextSelection.collapsed( + offset: selection.start + text.length, + ), + composing: TextRange.empty, + rawText: rawText, + type: type, + emote: emote, + uid: uid, + ); + } + + final newValue = delta.apply(oldValue); + + if (oldValue == newValue) { + return; + } + + editController + ..value = newValue + ..syncRichText(delta); + } else { + editController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + editController.items + ..clear() + ..add( + RichTextItem( + type: type, + text: text, + rawText: rawText, + range: TextRange( + start: 0, + end: text.length, + ), + emote: emote, + uid: uid, + ), + ); + } + } + + @override + void onSave() { + widget.onSave?.call(editController.items); + } +} diff --git a/lib/pages/common/publish/common_text_pub_page.dart b/lib/pages/common/publish/common_text_pub_page.dart new file mode 100644 index 00000000..a6f8c772 --- /dev/null +++ b/lib/pages/common/publish/common_text_pub_page.dart @@ -0,0 +1,29 @@ +import 'package:PiliPlus/pages/common/publish/common_publish_page.dart'; +import 'package:flutter/material.dart'; + +abstract class CommonTextPubPage extends CommonPublishPage { + const CommonTextPubPage({ + super.key, + super.initialValue, + super.onSave, + }); +} + +abstract class CommonTextPubPageState + extends CommonPublishPageState { + @override + late final TextEditingController editController = + TextEditingController(text: widget.initialValue); + + @override + void initPubState() { + if (widget.initialValue?.trim().isNotEmpty == true) { + enablePublish.value = true; + } + } + + @override + void onSave() { + widget.onSave?.call(editController.text); + } +} diff --git a/lib/pages/common/reply_controller.dart b/lib/pages/common/reply_controller.dart index 78727b7c..ae873860 100644 --- a/lib/pages/common/reply_controller.dart +++ b/lib/pages/common/reply_controller.dart @@ -1,10 +1,10 @@ +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show MainListReply, ReplyInfo, SubjectControl, Mode; import 'package:PiliPlus/grpc/bilibili/pagination.pb.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/reply.dart'; import 'package:PiliPlus/models/common/reply/reply_sort_type.dart'; -import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/services/account_service.dart'; @@ -25,8 +25,7 @@ abstract class ReplyController extends CommonListController { late Rx sortType; late Rx mode; - late final savedReplies = - ? mentions})?>{}; + late final savedReplies = ?>{}; AccountService accountService = Get.find(); @@ -127,16 +126,20 @@ abstract class ReplyController extends CommonListController { .push( GetDialogRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { - final saved = savedReplies[key]; return ReplyPage( oid: oid ?? replyItem!.oid.toInt(), root: oid != null ? 0 : replyItem!.id.toInt(), parent: oid != null ? 0 : replyItem!.id.toInt(), replyType: replyItem?.type.toInt() ?? replyType!, replyItem: replyItem, - initialValue: saved?.text, - mentions: saved?.mentions, - onSave: (reply) => savedReplies[key] = reply, + items: savedReplies[key], + onSave: (reply) { + if (reply.isEmpty) { + savedReplies.remove(key); + } else { + savedReplies[key] = reply.toList(); + } + }, hint: hint, ); }, @@ -238,4 +241,10 @@ abstract class ReplyController extends CommonListController { SmartDialog.showToast(res['msg']); } } + + @override + void onClose() { + savedReplies.clear(); + super.onClose(); + } } diff --git a/lib/pages/dynamics_create/view.dart b/lib/pages/dynamics_create/view.dart index 3914fec7..630796a0 100644 --- a/lib/pages/dynamics_create/view.dart +++ b/lib/pages/dynamics_create/view.dart @@ -5,13 +5,12 @@ import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart' as dyn_sheet; import 'package:PiliPlus/common/widgets/pair.dart'; -import 'package:PiliPlus/common/widgets/text_field/text_field.dart' - as text_field; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/reply/reply_option_type.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/view.dart'; @@ -26,7 +25,7 @@ import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class CreateDynPanel extends CommonPublishPage { +class CreateDynPanel extends CommonRichTextPubPage { const CreateDynPanel({ super.key, super.imageLengthLimit = 18, @@ -60,7 +59,7 @@ class CreateDynPanel extends CommonPublishPage { ); } -class _CreateDynPanelState extends CommonPublishPageState { +class _CreateDynPanelState extends CommonRichTextPubPageState { final RxBool _isPrivate = false.obs; final Rx _publishTime = Rx(null); final Rx _replyOption = ReplyOptionType.allow.obs; @@ -550,6 +549,12 @@ class _CreateDynPanelState extends CommonPublishPageState { tooltip: '@', selected: false, ), + // if (kDebugMode) + // ToolbarIconButton( + // onPressed: editController.clear, + // icon: const Icon(Icons.clear, size: 22), + // selected: false, + // ), ], ), ); @@ -563,13 +568,12 @@ class _CreateDynPanelState extends CommonPublishPageState { } }, child: Obx( - () => text_field.TextField( + () => RichTextField( controller: editController, minLines: 4, maxLines: null, focusNode: focusNode, readOnly: readOnly.value, - onDelAtUser: onDelAtUser, onChanged: onChanged, decoration: InputDecoration( hintText: '说点什么吧', @@ -580,8 +584,7 @@ class _CreateDynPanelState extends CommonPublishPageState { ), contentPadding: EdgeInsets.zero, ), - inputFormatters: [LengthLimitingTextInputFormatter(1000)], - onMention: onMention, + // inputFormatters: [LengthLimitingTextInputFormatter(1000)], ), ), ), @@ -591,14 +594,13 @@ class _CreateDynPanelState extends CommonPublishPageState { Widget? get customPanel => EmotePanel(onChoose: onChooseEmote); @override - Future onCustomPublish( - {required String message, List? pictures}) async { + Future onCustomPublish({List? pictures}) async { SmartDialog.showLoading(msg: '正在发布'); List>? extraContent = getRichContent(); - final hasMention = extraContent != null; + final hasRichText = extraContent != null; var result = await DynamicsHttp.createDynamic( mid: Accounts.main.mid, - rawText: hasMention ? null : editController.text, + rawText: hasRichText ? null : editController.text, pics: pictures, publishTime: _publishTime.value != null ? _publishTime.value!.millisecondsSinceEpoch ~/ 1000 @@ -611,13 +613,14 @@ class _CreateDynPanelState extends CommonPublishPageState { ); SmartDialog.dismiss(); if (result['status']) { + hasPub = true; Get.back(); SmartDialog.showToast('发布成功'); var id = result['data']?['dyn_id']; RequestUtils.insertCreatedDyn(id); RequestUtils.checkCreatedDyn( id: id, - dynText: editController.text, + dynText: editController.rawText, ); } else { SmartDialog.showToast(result['msg']); @@ -637,4 +640,7 @@ class _CreateDynPanelState extends CommonPublishPageState { } }); } + + @override + void onSave() {} } diff --git a/lib/pages/dynamics_repost/view.dart b/lib/pages/dynamics_repost/view.dart index 66c98112..6c644b07 100644 --- a/lib/pages/dynamics_repost/view.dart +++ b/lib/pages/dynamics_repost/view.dart @@ -6,18 +6,17 @@ import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/emote/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:flutter/material.dart' hide DraggableScrollableSheet, TextField; -import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class RepostPanel extends CommonPublishPage { +class RepostPanel extends CommonRichTextPubPage { const RepostPanel({ super.key, this.item, @@ -48,7 +47,7 @@ class RepostPanel extends CommonPublishPage { State createState() => _RepostPanelState(); } -class _RepostPanelState extends CommonPublishPageState { +class _RepostPanelState extends CommonRichTextPubPageState { late bool _isMax = widget.isMax ?? false; bool? _isExpanded; @@ -225,7 +224,7 @@ class _RepostPanelState extends CommonPublishPageState { } }, child: Obx( - () => TextField( + () => RichTextField( controller: editController, minLines: 4, maxLines: null, @@ -240,9 +239,7 @@ class _RepostPanelState extends CommonPublishPageState { ), contentPadding: const EdgeInsets.symmetric(vertical: 10), ), - inputFormatters: [LengthLimitingTextInputFormatter(1000)], - onMention: onMention, - onDelAtUser: onDelAtUser, + // inputFormatters: [LengthLimitingTextInputFormatter(1000)], ), ), ), @@ -376,7 +373,7 @@ class _RepostPanelState extends CommonPublishPageState { @override Widget? get customPanel => EmotePanel(onChoose: onChooseEmote); - List>? extraContent(DynamicItemModel item) { + List>? getRepostContent(DynamicItemModel item) { try { return [ {"raw_text": "//", "type": 1, "biz_id": ""}, @@ -416,26 +413,26 @@ class _RepostPanelState extends CommonPublishPageState { } @override - Future onCustomPublish( - {required String message, List? pictures}) async { + Future onCustomPublish({List? pictures}) async { SmartDialog.showLoading(); - List>? content = getRichContent(); - final hasMention = content != null; + List>? richContent = getRichContent(); + final hasRichText = richContent != null; List>? repostContent = - widget.item?.orig != null ? extraContent(widget.item!) : null; - if (hasMention && repostContent != null) { - content.addAll(repostContent); + widget.item?.orig != null ? getRepostContent(widget.item!) : null; + if (hasRichText && repostContent != null) { + richContent.addAll(repostContent); } var result = await DynamicsHttp.createDynamic( mid: Accounts.main.mid, dynIdStr: widget.item?.idStr ?? widget.dynIdStr, rid: widget.rid, dynType: widget.dynType, - rawText: hasMention ? null : editController.text, - extraContent: content ?? repostContent, + rawText: hasRichText ? null : editController.text, + extraContent: richContent ?? repostContent, ); SmartDialog.dismiss(); if (result['status']) { + hasPub = true; Get.back(); SmartDialog.showToast('转发成功'); widget.callback?.call(); @@ -443,10 +440,13 @@ class _RepostPanelState extends CommonPublishPageState { RequestUtils.insertCreatedDyn(id); RequestUtils.checkCreatedDyn( id: id, - dynText: editController.text, + dynText: editController.rawText, ); } else { SmartDialog.showToast(result['msg']); } } + + @override + void onSave() {} } diff --git a/lib/pages/emote/view.dart b/lib/pages/emote/view.dart index 50c5fe8b..26a5bcad 100644 --- a/lib/pages/emote/view.dart +++ b/lib/pages/emote/view.dart @@ -11,7 +11,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; class EmotePanel extends StatefulWidget { - final ValueChanged onChoose; + final Function(Emote emote, double? width, double? height) onChoose; + const EmotePanel({super.key, required this.onChoose}); @override @@ -46,18 +47,17 @@ class _EmotePanelState extends State controller: _emotePanelController.tabController, children: response!.map( (e) { - int size = e.emote!.first.meta!.size!; - int type = e.type!; + double size = e.emote!.first.meta!.size == 1 ? 40 : 60; + bool isTextEmote = e.type == 4; return GridView.builder( padding: const EdgeInsets.only( left: 12, right: 12, bottom: 12), gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: - type == 4 ? 100 : (size == 1 ? 40 : 60), + maxCrossAxisExtent: isTextEmote ? 100 : size, crossAxisSpacing: 8, mainAxisSpacing: 8, - mainAxisExtent: size == 1 ? 40 : 60, + mainAxisExtent: size, ), itemCount: e.emote!.length, itemBuilder: (context, index) { @@ -67,10 +67,17 @@ class _EmotePanelState extends State child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(8)), - onTap: () => widget.onChoose(item), + onTap: () => widget.onChoose( + item, + isTextEmote + ? null + : e.emote!.first.meta!.size == 1 + ? 24 + : 42, + null), child: Padding( padding: const EdgeInsets.all(6), - child: type == 4 + child: isTextEmote ? Center( child: Text( item.text!, @@ -80,8 +87,8 @@ class _EmotePanelState extends State ) : NetworkImgLayer( src: item.url!, - width: size * 38, - height: size * 38, + width: size, + height: size, semanticsLabel: item.text!, type: ImageType.emote, boxFit: BoxFit.contain, diff --git a/lib/pages/live_emote/view.dart b/lib/pages/live_emote/view.dart index d16c27d9..45396ab7 100644 --- a/lib/pages/live_emote/view.dart +++ b/lib/pages/live_emote/view.dart @@ -14,7 +14,7 @@ import 'package:get/get.dart'; class LiveEmotePanel extends StatefulWidget { final int roomId; - final ValueChanged onChoose; + final Function(Emoticon emote, double? width, double? height) onChoose; final ValueChanged onSendEmoticonUnique; const LiveEmotePanel({ super.key, @@ -61,6 +61,8 @@ class _LiveEmotePanelState extends State max(1, item.emoticons!.first.width! / 80); double heightFac = max(1, item.emoticons!.first.height! / 80); + final width = widthFac * 38; + final height = heightFac * 38; return GridView.builder( padding: const EdgeInsets.only( left: 12, right: 12, bottom: 12), @@ -81,7 +83,7 @@ class _LiveEmotePanelState extends State const BorderRadius.all(Radius.circular(8)), onTap: () { if (item.pkgType == 3) { - widget.onChoose(e); + widget.onChoose(e, width, height); } else { widget.onSendEmoticonUnique(e); } @@ -91,8 +93,8 @@ class _LiveEmotePanelState extends State child: NetworkImgLayer( boxFit: BoxFit.contain, src: e.url!, - width: widthFac * 38, - height: heightFac * 38, + width: width, + height: height, type: ImageType.emote, quality: item.pkgType == 3 ? null : 80, ), diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart index 1e4600db..4004a40e 100644 --- a/lib/pages/live_room/controller.dart +++ b/lib/pages/live_room/controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/http/video.dart'; @@ -48,7 +49,7 @@ class LiveRoomController extends GetxController { late List<({int code, String desc})> acceptQnList = []; RxString currentQnDesc = ''.obs; - String? savedDanmaku; + List? savedDanmaku; AccountService accountService = Get.find(); @@ -233,6 +234,8 @@ class LiveRoomController extends GetxController { @override void onClose() { + savedDanmaku?.clear(); + savedDanmaku = null; scrollController ..removeListener(listener) ..dispose(); diff --git a/lib/pages/live_room/send_danmaku/view.dart b/lib/pages/live_room/send_danmaku/view.dart index 3177a142..f78ab64a 100644 --- a/lib/pages/live_room/send_danmaku/view.dart +++ b/lib/pages/live_room/send_danmaku/view.dart @@ -1,24 +1,24 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; import 'package:PiliPlus/pages/live_emote/controller.dart'; import 'package:PiliPlus/pages/live_emote/view.dart'; import 'package:PiliPlus/pages/live_room/controller.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; +import 'package:flutter/material.dart' hide TextField; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide MultipartFile; -class LiveSendDmPanel extends CommonPublishPage { +class LiveSendDmPanel extends CommonRichTextPubPage { final bool fromEmote; final LiveRoomController liveRoomController; const LiveSendDmPanel({ super.key, - super.initialValue, + super.items, super.onSave, this.fromEmote = false, required this.liveRoomController, @@ -28,7 +28,7 @@ class LiveSendDmPanel extends CommonPublishPage { State createState() => _ReplyPageState(); } -class _ReplyPageState extends CommonPublishPageState { +class _ReplyPageState extends CommonRichTextPubPageState { LiveRoomController get liveRoomController => widget.liveRoomController; @override @@ -101,7 +101,7 @@ class _ReplyPageState extends CommonPublishPageState { } }, child: Obx( - () => TextField( + () => RichTextField( controller: editController, minLines: 1, maxLines: 2, @@ -115,7 +115,7 @@ class _ReplyPageState extends CommonPublishPageState { hintStyle: TextStyle(fontSize: 14), ), style: theme.textTheme.bodyLarge, - inputFormatters: [LengthLimitingTextInputFormatter(20)], + // inputFormatters: [LengthLimitingTextInputFormatter(20)], ), ), ), @@ -176,20 +176,23 @@ class _ReplyPageState extends CommonPublishPageState { @override Future onCustomPublish({ - required String message, + String? message, List? pictures, int? dmType, emoticonOptions, }) async { final res = await LiveHttp.sendLiveMsg( roomId: liveRoomController.roomId, - msg: message, + msg: message ?? editController.rawText, dmType: dmType, emoticonOptions: emoticonOptions, ); if (res['status']) { + hasPub = true; Get.back(); - liveRoomController.savedDanmaku = null; + liveRoomController + ..savedDanmaku?.clear() + ..savedDanmaku = null; SmartDialog.showToast('发送成功'); } else { SmartDialog.showToast(res['msg']); diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 9279e77f..8bcdcaa7 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -606,8 +606,16 @@ class _LiveRoomPageState extends State return LiveSendDmPanel( fromEmote: fromEmote, liveRoomController: _liveRoomController, - initialValue: _liveRoomController.savedDanmaku, - onSave: (msg) => _liveRoomController.savedDanmaku = msg.text, + items: _liveRoomController.savedDanmaku, + onSave: (msg) { + if (msg.isEmpty) { + _liveRoomController + ..savedDanmaku?.clear() + ..savedDanmaku = null; + } else { + _liveRoomController.savedDanmaku = msg.toList(); + } + }, ); }, transitionDuration: const Duration(milliseconds: 500), diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index ef02b4d7..4ff52614 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -945,7 +945,7 @@ class VideoDetailController extends GetxController bvid: bvid, progress: plPlayerController.position.value.inMilliseconds, initialValue: savedDanmaku, - onSave: (danmaku) => savedDanmaku = danmaku.text, + onSave: (danmaku) => savedDanmaku = danmaku, callback: (danmakuModel) { savedDanmaku = null; plPlayerController.danmakuController?.addDanmaku(danmakuModel); diff --git a/lib/pages/video/reply_new/view.dart b/lib/pages/video/reply_new/view.dart index 98ab04e7..73f6a1e3 100644 --- a/lib/pages/video/reply_new/view.dart +++ b/lib/pages/video/reply_new/view.dart @@ -1,13 +1,15 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; +import 'package:PiliPlus/common/widgets/text_field/controller.dart' + show RichTextType; import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -15,7 +17,7 @@ import 'package:flutter/material.dart' hide TextField; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class ReplyPage extends CommonPublishPage { +class ReplyPage extends CommonRichTextPubPage { final int oid; final int root; final int parent; @@ -25,8 +27,7 @@ class ReplyPage extends CommonPublishPage { const ReplyPage({ super.key, - super.initialValue, - super.mentions, + super.items, super.imageLengthLimit, super.onSave, required this.oid, @@ -41,7 +42,7 @@ class ReplyPage extends CommonPublishPage { State createState() => _ReplyPageState(); } -class _ReplyPageState extends CommonPublishPageState { +class _ReplyPageState extends CommonRichTextPubPageState { final RxBool _syncToDynamic = false.obs; Widget get child => SafeArea( @@ -137,7 +138,7 @@ class _ReplyPageState extends CommonPublishPageState { } }, child: Obx( - () => TextField( + () => RichTextField( controller: editController, minLines: 4, maxLines: 8, @@ -151,8 +152,6 @@ class _ReplyPageState extends CommonPublishPageState { hintStyle: const TextStyle(fontSize: 14), ), style: themeData.textTheme.bodyLarge, - onMention: onMention, - onDelAtUser: onDelAtUser, ), ), ), @@ -265,8 +264,12 @@ class _ReplyPageState extends CommonPublishPageState { } @override - Future onCustomPublish( - {required String message, List? pictures}) async { + Future onCustomPublish({List? pictures}) async { + Map atNameToMid = { + for (var e in editController.items) + if (e.type == RichTextType.at) e.rawText: int.parse(e.uid!), + }; + String message = editController.rawText; var result = await VideoHttp.replyAdd( type: widget.replyType, oid: widget.oid, @@ -275,13 +278,12 @@ class _ReplyPageState extends CommonPublishPageState { message: widget.replyItem != null && widget.replyItem!.root != 0 ? ' 回复 @${widget.replyItem!.member.name} : $message' : message, - atNameToMid: mentions?.isNotEmpty == true - ? {for (var e in mentions!) e.name: e.uid} - : null, + atNameToMid: atNameToMid, pictures: pictures, syncToDynamic: _syncToDynamic.value, ); if (result['status']) { + hasPub = true; SmartDialog.showToast(result['data']['success_toast']); Get.back(result: result['data']['reply']); } else { diff --git a/lib/pages/video/reply_reply/controller.dart b/lib/pages/video/reply_reply/controller.dart index ad63310e..0200d98c 100644 --- a/lib/pages/video/reply_reply/controller.dart +++ b/lib/pages/video/reply_reply/controller.dart @@ -149,16 +149,20 @@ class VideoReplyReplyController extends ReplyController .push( GetDialogRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { - final saved = savedReplies[key]; return ReplyPage( oid: oid, root: root, parent: root, replyType: this.replyType, replyItem: replyItem, - initialValue: saved?.text, - mentions: saved?.mentions, - onSave: (reply) => savedReplies[key] = reply, + items: savedReplies[key], + onSave: (reply) { + if (reply.isEmpty) { + savedReplies.remove(key); + } else { + savedReplies[key] = reply.toList(); + } + }, ); }, transitionDuration: const Duration(milliseconds: 500), diff --git a/lib/pages/video/send_danmaku/view.dart b/lib/pages/video/send_danmaku/view.dart index 80de5871..7c24be72 100644 --- a/lib/pages/video/send_danmaku/view.dart +++ b/lib/pages/video/send_danmaku/view.dart @@ -5,7 +5,7 @@ import 'package:PiliPlus/http/danmaku.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/user/info.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_text_pub_page.dart'; import 'package:PiliPlus/pages/setting/slide_color_picker.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:canvas_danmaku/models/danmaku_content_item.dart'; @@ -14,7 +14,7 @@ import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class SendDanmakuPanel extends CommonPublishPage { +class SendDanmakuPanel extends CommonTextPubPage { // video final dynamic cid; final dynamic bvid; @@ -44,7 +44,7 @@ class SendDanmakuPanel extends CommonPublishPage { State createState() => _SendDanmakuPanelState(); } -class _SendDanmakuPanelState extends CommonPublishPageState { +class _SendDanmakuPanelState extends CommonTextPubPageState { late final RxInt _mode; late final RxInt _fontsize; late final Rx _color; @@ -448,8 +448,7 @@ class _SendDanmakuPanelState extends CommonPublishPageState { } @override - Future onCustomPublish( - {required String message, List? pictures}) async { + Future onCustomPublish({List? pictures}) async { SmartDialog.showLoading(msg: '发送中...'); bool isColorful = _color.value == Colors.transparent; final res = await DanmakuHttp.shootDanmaku( @@ -464,6 +463,7 @@ class _SendDanmakuPanelState extends CommonPublishPageState { ); SmartDialog.dismiss(); if (res['status']) { + hasPub = true; Get.back(); SmartDialog.showToast('发送成功'); widget.callback( diff --git a/lib/pages/whisper_detail/controller.dart b/lib/pages/whisper_detail/controller.dart index d2279ed5..f92c108d 100644 --- a/lib/pages/whisper_detail/controller.dart +++ b/lib/pages/whisper_detail/controller.dart @@ -65,12 +65,13 @@ class WhisperDetailController extends CommonListController { } Future sendMsg({ - required String message, + String? message, Map? picMsg, required VoidCallback onClearText, int? msgType, int? index, }) async { + assert((message != null) ^ (picMsg != null)); feedBack(); SmartDialog.dismiss(); if (!accountService.isLogin.value) { @@ -81,7 +82,7 @@ class WhisperDetailController extends CommonListController { senderUid: accountService.mid, receiverId: mid!, content: - msgType == 5 ? message : jsonEncode(picMsg ?? {"content": message}), + msgType == 5 ? message! : jsonEncode(picMsg ?? {"content": message!}), msgType: MsgType.values[msgType ?? (picMsg != null ? 2 : 1)], ); SmartDialog.dismiss(); diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart index 89f56205..2746b700 100644 --- a/lib/pages/whisper_detail/view.dart +++ b/lib/pages/whisper_detail/view.dart @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/grpc/bilibili/im/type.pb.dart' show Msg; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models_new/upload_bfs/data.dart'; -import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/pages/whisper_detail/controller.dart'; import 'package:PiliPlus/pages/whisper_detail/widget/chat_item.dart'; @@ -17,14 +18,13 @@ import 'package:PiliPlus/pages/whisper_link_setting/view.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; +import 'package:flutter/material.dart' hide TextField; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; -class WhisperDetailPage extends CommonPublishPage { +class WhisperDetailPage extends CommonRichTextPubPage { const WhisperDetailPage({ super.key, super.autofocus = false, @@ -35,7 +35,7 @@ class WhisperDetailPage extends CommonPublishPage { } class _WhisperDetailPageState - extends CommonPublishPageState { + extends CommonRichTextPubPageState { final _whisperDetailController = Get.put( WhisperDetailController(), tag: Utils.makeHeroTag(Get.parameters['talkerId']), @@ -239,7 +239,7 @@ class _WhisperDetailPageState } }, child: Obx( - () => TextField( + () => RichTextField( readOnly: readOnly.value, focusNode: focusNode, controller: editController, @@ -258,7 +258,7 @@ class _WhisperDetailPageState ), contentPadding: const EdgeInsets.all(10), ), - inputFormatters: [LengthLimitingTextInputFormatter(500)], + // inputFormatters: [LengthLimitingTextInputFormatter(500)], ), ), ), @@ -269,7 +269,7 @@ class _WhisperDetailPageState onPressed: () async { if (enablePublish.value) { _whisperDetailController.sendMsg( - message: editController.text, + message: editController.rawText, onClearText: editController.clear, ); } else { @@ -301,7 +301,6 @@ class _WhisperDetailPageState SmartDialog.showLoading(msg: '正在发送'); await _whisperDetailController.sendMsg( picMsg: picMsg, - message: editController.text, onClearText: editController.clear, ); } else { @@ -331,7 +330,13 @@ class _WhisperDetailPageState Widget? get customPanel => EmotePanel(onChoose: onChooseEmote); @override - Future onCustomPublish({required String message, List? pictures}) { + Future onCustomPublish({List? pictures}) { throw UnimplementedError(); } + + @override + void onMention([bool fromClick = false]) {} + + @override + void onSave() {} }