Files
Qlyra/lib/frontend/widgets/rich_message_controller.dart
T

452 lines
12 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../core/utils/text_format.dart';
import '../../models/animoji.dart';
import 'formatted_message_text.dart';
import 'lottie_image.dart';
const List<TextFormat> composerFormats = [
TextFormat.strong,
TextFormat.emphasized,
TextFormat.underline,
TextFormat.strikethrough,
TextFormat.quote,
];
class _Interval {
int start;
int end;
_Interval(this.start, this.end);
}
class _MentionEntity {
int start;
int end;
final int userId;
_MentionEntity(this.start, this.end, this.userId);
}
class _AnimojiEntity {
final int uid;
int offset;
final String emoji;
final String lottieUrl;
final int entityId;
_AnimojiEntity({
required this.uid,
required this.offset,
required this.emoji,
required this.lottieUrl,
required this.entityId,
});
}
class RichMessageController extends TextEditingController {
static const String _animojiPlaceholder = '';
final Map<TextFormat, List<_Interval>> _intervals = {};
final List<_AnimojiEntity> _animoji = [];
final List<_MentionEntity> _mentions = [];
int _entitySeq = 0;
RichMessageController({super.text});
void insertAnimoji(Animoji animoji) {
final lottie = animoji.lottieUrl ?? animoji.lottiePlayUrl;
if (lottie == null || lottie.isEmpty) return;
final selection = value.selection;
final oldText = value.text;
final start = selection.isValid ? selection.start : oldText.length;
final end = selection.isValid ? selection.end : oldText.length;
final newText = oldText.replaceRange(start, end, _animojiPlaceholder);
value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + _animojiPlaceholder.length,
),
);
_animoji.add(
_AnimojiEntity(
uid: _entitySeq++,
offset: start,
emoji: animoji.emoji,
lottieUrl: lottie,
entityId: animoji.id,
),
);
_animoji.sort((a, b) => a.offset.compareTo(b.offset));
notifyListeners();
}
void insertMention({
required int userId,
required String name,
required int start,
required int end,
}) {
final oldText = value.text;
if (start < 0 || end > oldText.length || start > end || name.isEmpty) {
return;
}
final inserted = '$name ';
value = TextEditingValue(
text: oldText.replaceRange(start, end, inserted),
selection: TextSelection.collapsed(offset: start + inserted.length),
);
_mentions.add(_MentionEntity(start, start + name.length, userId));
_mentions.sort((a, b) => a.start.compareTo(b.start));
notifyListeners();
}
({String text, List<Map<String, dynamic>> elements}) buildContent() {
final src = value.text;
if (_animoji.isEmpty) {
return (text: src, elements: elementsForSend());
}
final entities = [..._animoji]
..sort((a, b) => a.offset.compareTo(b.offset));
final sb = StringBuffer();
var last = 0;
for (final e in entities) {
if (e.offset < last || e.offset >= src.length) continue;
sb.write(src.substring(last, e.offset));
sb.write(e.emoji);
last = e.offset + _animojiPlaceholder.length;
}
sb.write(src.substring(last));
final glyphText = sb.toString();
int glyphOffset(int p) {
var shift = 0;
for (final e in entities) {
if (e.offset < p && e.offset < src.length) {
shift += e.emoji.length - _animojiPlaceholder.length;
}
}
return p + shift;
}
final elements = <Map<String, dynamic>>[];
for (final e in entities) {
if (e.offset >= src.length) continue;
elements.add({
'type': 'ANIMOJI',
'from': glyphOffset(e.offset),
'length': e.emoji.length,
'entityId': e.entityId,
'attributes': {'animojiLottieUrl': e.lottieUrl},
});
}
for (final range in _toFormatRanges()) {
final from = glyphOffset(range.start);
final to = glyphOffset(range.end);
if (to <= from) continue;
elements.add({
'type': textFormatToServer(range.format),
'from': from,
'length': to - from,
if (range.entityId != null) 'entityId': range.entityId,
});
}
return (text: glyphText, elements: elements);
}
@override
set value(TextEditingValue newValue) {
final oldText = value.text;
final newText = newValue.text;
if (oldText != newText) {
_remap(oldText, newText);
}
super.value = newValue;
}
bool get hasFormatting =>
_intervals.values.any((list) => list.isNotEmpty) || _mentions.isNotEmpty;
void clearFormatting() {
if (_intervals.isEmpty) return;
_intervals.clear();
notifyListeners();
}
void setFormatRanges(Iterable<FormatRange> ranges) {
_intervals.clear();
_mentions.clear();
for (final range in ranges) {
if (range.format == TextFormat.userMention) {
final userId = range.entityId;
if (userId != null) {
_mentions.add(_MentionEntity(range.start, range.end, userId));
}
continue;
}
if (!composerFormats.contains(range.format)) continue;
_intervals
.putIfAbsent(range.format, () => [])
.add(_Interval(range.start, range.end));
}
_mentions.sort((a, b) => a.start.compareTo(b.start));
for (final list in _intervals.values) {
_normalize(list);
}
notifyListeners();
}
List<Map<String, dynamic>> elementsForSend() {
return serializeFormatElements(_toFormatRanges());
}
List<FormatRange> _toFormatRanges() {
final ranges = <FormatRange>[];
_intervals.forEach((format, list) {
for (final interval in list) {
ranges.add(
FormatRange(
format: format,
start: interval.start,
length: interval.end - interval.start,
),
);
}
});
for (final mention in _mentions) {
ranges.add(
FormatRange(
format: TextFormat.userMention,
start: mention.start,
length: mention.end - mention.start,
entityId: mention.userId,
),
);
}
return ranges;
}
bool isFormatActive(TextFormat format) {
final selection = value.selection;
if (!selection.isValid || selection.isCollapsed) return false;
return _isCovered(_intervals[format], selection.start, selection.end);
}
void toggleFormat(TextFormat format) {
final selection = value.selection;
if (!selection.isValid || selection.isCollapsed) return;
final start = selection.start;
final end = selection.end;
final list = _intervals.putIfAbsent(format, () => []);
if (_isCovered(list, start, end)) {
_subtract(list, start, end);
} else {
_add(list, start, end);
}
if (list.isEmpty) _intervals.remove(format);
notifyListeners();
}
void _remap(String oldText, String newText) {
if (_intervals.isEmpty && _animoji.isEmpty && _mentions.isEmpty) return;
final oldLen = oldText.length;
final newLen = newText.length;
var prefix = 0;
final maxPrefix = oldLen < newLen ? oldLen : newLen;
while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) {
prefix++;
}
var suffix = 0;
while (suffix < maxPrefix - prefix &&
oldText[oldLen - 1 - suffix] == newText[newLen - 1 - suffix]) {
suffix++;
}
final changeStart = prefix;
final oldChangeEnd = oldLen - suffix;
final delta = newLen - oldLen;
int mapStart(int offset) {
if (offset < changeStart) return offset;
if (offset >= oldChangeEnd) return offset + delta;
return changeStart;
}
int mapEnd(int offset) {
if (offset <= changeStart) return offset;
if (offset >= oldChangeEnd) return offset + delta;
return changeStart;
}
if (_animoji.isNotEmpty) {
_animoji.removeWhere(
(e) => e.offset >= changeStart && e.offset < oldChangeEnd,
);
for (final e in _animoji) {
if (e.offset >= oldChangeEnd) e.offset += delta;
}
}
if (_mentions.isNotEmpty) {
_mentions.removeWhere(
(mention) => changeStart < mention.end && oldChangeEnd > mention.start,
);
for (final mention in _mentions) {
mention.start = mapStart(mention.start);
mention.end = mapEnd(mention.end);
}
}
final empty = <TextFormat>[];
_intervals.forEach((format, list) {
for (final interval in list) {
interval.start = mapStart(interval.start);
interval.end = mapEnd(interval.end);
}
list.removeWhere((interval) => interval.end <= interval.start);
_normalize(list);
if (list.isEmpty) empty.add(format);
});
for (final format in empty) {
_intervals.remove(format);
}
}
static bool _isCovered(List<_Interval>? list, int start, int end) {
if (list == null || list.isEmpty) return false;
var cursor = start;
final sorted = [...list]..sort((a, b) => a.start.compareTo(b.start));
for (final interval in sorted) {
if (interval.start > cursor) return false;
if (interval.end > cursor) cursor = interval.end;
if (cursor >= end) return true;
}
return cursor >= end;
}
static void _add(List<_Interval> list, int start, int end) {
list.add(_Interval(start, end));
_normalize(list);
}
static void _subtract(List<_Interval> list, int start, int end) {
final result = <_Interval>[];
for (final interval in list) {
if (interval.end <= start || interval.start >= end) {
result.add(interval);
continue;
}
if (interval.start < start) {
result.add(_Interval(interval.start, start));
}
if (interval.end > end) {
result.add(_Interval(end, interval.end));
}
}
list
..clear()
..addAll(result);
_normalize(list);
}
static void _normalize(List<_Interval> list) {
if (list.length < 2) return;
list.sort((a, b) => a.start.compareTo(b.start));
final merged = <_Interval>[list.first];
for (var i = 1; i < list.length; i++) {
final current = list[i];
final last = merged.last;
if (current.start <= last.end) {
if (current.end > last.end) last.end = current.end;
} else {
merged.add(current);
}
}
list
..clear()
..addAll(merged);
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final baseStyle = style ?? const TextStyle();
final content = text;
if ((!hasFormatting && _animoji.isEmpty) || content.isEmpty) {
return TextSpan(style: baseStyle, text: content);
}
final ranges = _toFormatRanges();
final baseColor = baseStyle.color;
final quoteColor = baseColor?.withValues(alpha: 0.85);
final quoteBackground = baseColor == null
? null
: (Paint()..color = baseColor.withValues(alpha: 0.12));
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
final segments = segmentizeFormats(content, ranges);
final entityByOffset = {for (final e in _animoji) e.offset: e};
final box = (baseStyle.fontSize ?? 16) * 1.4;
final spans = <InlineSpan>[];
for (final segment in segments) {
final segStyle = applyTextFormats(
baseStyle,
segment.formats,
quoteColor: quoteColor,
mentionColor: mentionColor,
quoteBackground: quoteBackground,
);
var runStart = segment.start;
var i = segment.start;
while (i < segment.end) {
final entity = entityByOffset[i];
if (entity == null) {
i++;
continue;
}
if (runStart < i) {
spans.add(
TextSpan(text: content.substring(runStart, i), style: segStyle),
);
}
spans.add(_animojiSpan(entity, box));
i += _animojiPlaceholder.length;
runStart = i;
}
if (runStart < segment.end) {
spans.add(
TextSpan(
text: content.substring(runStart, segment.end),
style: segStyle,
),
);
}
}
return TextSpan(style: baseStyle, children: spans);
}
WidgetSpan _animojiSpan(_AnimojiEntity entity, double box) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: SizedBox(
key: ValueKey('composer-animoji-${entity.uid}'),
width: box,
height: box,
child: LottieImage(
lottieUrl: entity.lottieUrl,
size: box,
memCacheWidth: 120,
),
),
);
}
}