From 6bd7518109850d650a174b74e5582367555390da Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 1 Jul 2022 12:55:26 +0400 Subject: [PATCH] Fix editing text with custom emoji and formatting. --- ui/text/text_entity.cpp | 55 ++++++++++--- ui/text/text_entity.h | 31 ++------ ui/widgets/input_fields.cpp | 150 ++++++++++++++++++++++++++++++++---- ui/widgets/input_fields.h | 35 +++++++-- 4 files changed, 213 insertions(+), 58 deletions(-) diff --git a/ui/text/text_entity.cpp b/ui/text/text_entity.cpp index 96fa427..d5fd44a 100644 --- a/ui/text/text_entity.cpp +++ b/ui/text/text_entity.cpp @@ -1527,6 +1527,29 @@ bool CutPart(TextWithEntities &sending, TextWithEntities &left, int32 limit) { return true; } +MentionNameFields MentionNameDataToFields(QStringView data) { + const auto components = data.split('.'); + if (components.size() != 2) { + return {}; + } + const auto parts = components[1].split(':'); + if (parts.size() != 2) { + return {}; + } + return { + .selfId = parts[1].toULongLong(), + .userId = components[0].toULongLong(), + .accessHash = parts[0].toULongLong(), + }; +} + +QString MentionNameDataFromFields(const MentionNameFields &fields) { + return u"%1.%2:%3"_q + .arg(fields.userId) + .arg(fields.accessHash) + .arg(fields.selfId); +} + TextWithEntities ParseEntities(const QString &text, int32 flags) { auto result = TextWithEntities{ text, EntitiesInText() }; ParseEntities(result, flags); @@ -1929,7 +1952,14 @@ bool IsMentionLink(QStringView link) { return link.startsWith(kMentionTagStart); } -[[nodiscard]] bool IsSeparateTag(QStringView tag) { +QString MentionEntityData(QStringView link) { + const auto match = qthelp::regex_match( + "^(\\d+\\.\\d+:\\d+)(/|$)", + base::StringViewMid(link, kMentionTagStart.size())); + return match ? match->captured(1) : QString(); +} + +bool IsSeparateTag(QStringView tag) { return (tag == Ui::InputField::kTagCode) || (tag == Ui::InputField::kTagPre); } @@ -1953,8 +1983,8 @@ QString JoinTag(const QList &list) { return result; } -QList SplitTags(const QString &tag) { - return QStringView(tag).split(kTagSeparator); +QList SplitTags(QStringView tag) { + return tag.split(kTagSeparator); } QString TagWithRemoved(const QString &tag, const QString &removed) { @@ -2073,11 +2103,9 @@ EntitiesInText ConvertTextTagsToEntities(const TextWithTags::Tags &tags) { openType(EntityType::CustomEmoji, data); } } else if (IsMentionLink(nextState.link)) { - const auto match = qthelp::regex_match( - "^(\\d+\\.\\d+)(/|$)", - base::StringViewMid(nextState.link, kMentionTagStart.size())); - if (match) { - openType(EntityType::MentionName, match->captured(1)); + const auto data = MentionEntityData(nextState.link); + if (!data.isEmpty()) { + openType(EntityType::MentionName, data); } } else { openType(EntityType::CustomUrl, nextState.link); @@ -2169,8 +2197,8 @@ TextWithTags::Tags ConvertEntitiesToTextTags( }; switch (entity.type()) { case EntityType::MentionName: { - auto match = QRegularExpression( - R"(^(\d+\.\d+)$)" + const auto match = QRegularExpression( + "^(\\d+\\.\\d+:\\d+)$" ).match(entity.data()); if (match.hasMatch()) { push(kMentionTagStart + entity.data()); @@ -2184,7 +2212,12 @@ TextWithTags::Tags ConvertEntitiesToTextTags( } } break; case EntityType::CustomEmoji: { - push(Ui::InputField::CustomEmojiLink(entity.data())); + const auto match = QRegularExpression( + "^(\\d+\\.\\d+:\\d+/\\d+)$" + ).match(entity.data()); + if (match.hasMatch()) { + push(Ui::InputField::CustomEmojiLink(entity.data())); + } } break; case EntityType::Bold: push(Ui::InputField::kTagBold); break; //case EntityType::Semibold: // Semibold is for UI parts only. diff --git a/ui/text/text_entity.h b/ui/text/text_entity.h index fc406ab..0a18f83 100644 --- a/ui/text/text_entity.h +++ b/ui/text/text_entity.h @@ -306,31 +306,13 @@ QStringList PrepareSearchWords(const QString &query, const QRegularExpression *S bool CutPart(TextWithEntities &sending, TextWithEntities &left, int limit); struct MentionNameFields { - MentionNameFields(uint64 userId = 0, uint64 accessHash = 0) - : userId(userId), accessHash(accessHash) { - } + uint64 selfId = 0; uint64 userId = 0; uint64 accessHash = 0; }; - -inline MentionNameFields MentionNameDataToFields(const QString &data) { - auto components = data.split('.'); - if (!components.isEmpty()) { - return { - components.at(0).toULongLong(), - (components.size() > 1) ? components.at(1).toULongLong() : 0 - }; - } - return MentionNameFields{}; -} - -inline QString MentionNameDataFromFields(const MentionNameFields &fields) { - auto result = QString::number(fields.userId); - if (fields.accessHash) { - result += '.' + QString::number(fields.accessHash); - } - return result; -} +[[nodiscard]] MentionNameFields MentionNameDataToFields(QStringView data); +[[nodiscard]] QString MentionNameDataFromFields( + const MentionNameFields &fields); // New entities are added to the ones that are already in result. // Changes text if (flags & TextParseMarkdown). @@ -362,12 +344,13 @@ void ApplyServerCleaning(TextWithEntities &result); [[nodiscard]] QString TagsMimeType(); [[nodiscard]] QString TagsTextMimeType(); -inline const auto kMentionTagStart = qstr("mention://user."); +inline const auto kMentionTagStart = qstr("mention://"); [[nodiscard]] bool IsMentionLink(QStringView link); +[[nodiscard]] QString MentionEntityData(QStringView link); [[nodiscard]] bool IsSeparateTag(QStringView tag); [[nodiscard]] QString JoinTag(const QList &list); -[[nodiscard]] QList SplitTags(const QString &tag); +[[nodiscard]] QList SplitTags(QStringView tag); [[nodiscard]] QString TagWithRemoved( const QString &tag, const QString &removed); diff --git a/ui/widgets/input_fields.cpp b/ui/widgets/input_fields.cpp index 6e4cf08..4a521f4 100644 --- a/ui/widgets/input_fields.cpp +++ b/ui/widgets/input_fields.cpp @@ -38,6 +38,7 @@ constexpr auto kTagProperty = QTextFormat::UserProperty + 4; constexpr auto kCustomEmojiFormat = QTextFormat::UserObject + 1; constexpr auto kCustomEmojiText = QTextFormat::UserProperty + 5; constexpr auto kCustomEmojiLink = QTextFormat::UserProperty + 6; +constexpr auto kCustomEmojiId = QTextFormat::UserProperty + 7; const auto kObjectReplacementCh = QChar(QChar::ObjectReplacementCharacter); const auto kObjectReplacement = QString::fromRawData( &kObjectReplacementCh, @@ -127,6 +128,19 @@ bool IsNewline(QChar ch) { .arg(++GlobalCustomEmojiCounter); } +[[nodiscard]] uint64 CustomEmojiIdFromLink(QStringView link) { + const auto skip = Ui::InputField::kCustomEmojiTagStart.size(); + if (const auto i = link.indexOf('/', skip + 1); i > 0) { + const auto j = link.indexOf('?', i + 1); + return base::StringViewMid( + link, + i + 1, + (j > i) ? (j - i - 1) : -1 + ).toULongLong(); + } + return 0; +} + [[nodiscard]] QString CheckFullTextTag( const TextWithTags &textWithTags, const QString &tag) { @@ -733,6 +747,9 @@ QTextCharFormat PrepareTagFormat( replaceWith = MakeUniqueCustomEmojiLink(tag); result.setObjectType(kCustomEmojiFormat); result.setProperty(kCustomEmojiLink, replaceWith); + result.setProperty( + kCustomEmojiId, + CustomEmojiIdFromLink(replaceWith)); } else if (IsValidMarkdownLink(tag)) { color = st::defaultTextPalette.linkFg; } else if (tag == kTagBold) { @@ -762,10 +779,40 @@ QTextCharFormat PrepareTagFormat( : std::move(tag).replace(replaceWhat, replaceWith))); if (bg) { result.setBackground(*bg); + } else { + result.setBackground(QBrush()); } return result; } +[[nodiscard]] QString TagWithoutCustomEmoji(QStringView tag) { + auto tags = TextUtilities::SplitTags(tag); + for (auto i = tags.begin(); i != tags.end();) { + if (IsCustomEmojiLink(*i)) { + i = tags.erase(i); + } else { + ++i; + } + } + return TextUtilities::JoinTag(tags); +} + +void RemoveCustomEmojiTag( + const style::InputField &st, + not_null document, + const QString &existingTags, + int from, + int end) { + auto cursor = QTextCursor(document); + cursor.setPosition(from); + cursor.setPosition(end, QTextCursor::KeepAnchor); + + auto format = PrepareTagFormat(st, TagWithoutCustomEmoji(existingTags)); + format.setProperty(kCustomEmojiLink, QString()); + format.setProperty(kCustomEmojiId, QString()); + cursor.mergeCharFormat(format); +} + void ApplyTagFormat(QTextCharFormat &to, const QTextCharFormat &from) { to.setProperty(kTagProperty, from.property(kTagProperty)); to.setProperty(kReplaceTagId, from.property(kReplaceTagId)); @@ -781,7 +828,7 @@ int ProcessInsertedTags( int changedPosition, int changedEnd, const TextWithTags::Tags &tags, - InputField::TagMimeProcessor *processor) { + Fn processor) { int firstTagStart = changedEnd; int applyNoTagFrom = changedEnd; for (const auto &tag : tags) { @@ -789,7 +836,7 @@ int ProcessInsertedTags( int tagTo = tagFrom + tag.length; accumulate_max(tagFrom, changedPosition); accumulate_min(tagTo, changedEnd); - auto tagId = processor ? processor->tagFromMimeTag(tag.id) : tag.id; + auto tagId = processor ? processor(tag.id) : tag.id; if (tagTo > tagFrom && !tagId.isEmpty()) { accumulate_min(firstTagStart, tagFrom); @@ -833,7 +880,9 @@ bool WasInsertTillTheEndOfTag( const auto outsideInsertion = (position >= insertionEnd); if (outsideInsertion) { const auto format = fragment.charFormat(); - return (format.property(kTagProperty) != insertTagName); + const auto tag = format.property(kTagProperty).toString(); + return TagWithoutCustomEmoji(tag) + != TagWithoutCustomEmoji(insertTagName.toString()); } const auto end = position + fragment.length(); const auto notFullFragmentInserted = (end > insertionEnd); @@ -857,6 +906,7 @@ struct FormattingAction { Invalid, InsertEmoji, InsertCustomEmoji, + RemoveCustomEmoji, TildeFont, RemoveTag, RemoveNewline, @@ -867,6 +917,7 @@ struct FormattingAction { EmojiPtr emoji = nullptr; bool isTilde = false; QString tildeTag; + QString existingTags; QString customEmojiText; QString customEmojiLink; int intervalStart = 0; @@ -949,6 +1000,7 @@ void InsertCustomEmojiAtCursor( format.setObjectType(kCustomEmojiFormat); format.setProperty(kCustomEmojiText, text); format.setProperty(kCustomEmojiLink, MakeUniqueCustomEmojiLink(link)); + format.setProperty(kCustomEmojiId, CustomEmojiIdFromLink(link)); format.setVerticalAlignment(QTextCharFormat::AlignBottom); ApplyTagFormat(format, currentFormat); cursor.insertText(kObjectReplacement, format); @@ -1315,9 +1367,14 @@ void FlatInput::onTextChange(const QString &text) { Integration::Instance().textActionsUpdated(); } -CustomEmojiObject::CustomEmojiObject(QObject *parent) : QObject(parent) { +CustomEmojiObject::CustomEmojiObject(Factory factory, Fn paused) +: _factory(std::move(factory)) +, _paused(std::move(paused)) +, _now(crl::now()) { } +CustomEmojiObject::~CustomEmojiObject() = default; + QSizeF CustomEmojiObject::intrinsicSize( QTextDocument *doc, int posInDocument, @@ -1326,7 +1383,7 @@ QSizeF CustomEmojiObject::intrinsicSize( const auto size = Emoji::GetSizeNormal() / factor; const auto width = size + st::emojiPadding * 2.; const auto font = format.toCharFormat().font(); - const auto height = std::max(QFontMetrics(font).height() * 1., size); + const auto height = std::min(QFontMetrics(font).height() * 1., size); return { width, height }; } @@ -1336,7 +1393,36 @@ void CustomEmojiObject::drawObject( QTextDocument *doc, int posInDocument, const QTextFormat &format) { - painter->fillRect(rect, QColor(0, 128, 0, 128)); + const auto id = format.property(kCustomEmojiId).toULongLong(); + if (!id) { + return; + } + auto i = _emoji.find(id); + if (i == end(_emoji)) { + const auto link = format.property(kCustomEmojiLink).toString(); + const auto data = InputField::CustomEmojiEntityData(link); + if (auto emoji = _factory(data)) { + i = _emoji.emplace(id, std::move(emoji)).first; + } + } + if (i == end(_emoji)) { + return; + } + i->second->paint( + *painter, + int(base::SafeRound(rect.x())) + st::emojiPadding, + int(base::SafeRound(rect.y())), + _now, + st::defaultTextPalette.spoilerActiveBg->c, + _paused()); +} + +void CustomEmojiObject::clear() { + _emoji.clear(); +} + +void CustomEmojiObject::setNow(crl::time now) { + _now = now; } InputField::InputField( @@ -1388,10 +1474,6 @@ InputField::InputField( setAttribute(Qt::WA_OpaquePaintEvent); } - _inner->document()->documentLayout()->registerHandler( - kCustomEmojiFormat, - new CustomEmojiObject(this)); - _inner->setFont(_st.font->f); _inner->setAlignment(_st.textAlign); if (_mode == Mode::SingleLine) { @@ -1473,6 +1555,8 @@ bool InputField::viewportEventInner(QEvent *e) { if (ev->device()->type() == base::TouchDevice::TouchScreen) { handleTouchEvent(ev); } + } else if (e->type() == QEvent::Paint && _customEmojiObject) { + _customEmojiObject->setNow(crl::now()); } return _inner->QTextEdit::viewportEvent(e); } @@ -1566,11 +1650,22 @@ void InputField::setMarkdownReplacesEnabled(rpl::producer enabled) { }, lifetime()); } -void InputField::setTagMimeProcessor( - std::unique_ptr &&processor) { +void InputField::setTagMimeProcessor(Fn processor) { _tagMimeProcessor = std::move(processor); } +void InputField::setCustomEmojiFactory( + CustomEmojiFactory factory, + Fn paused) { + _customEmojiObject = std::make_unique([=]( + QStringView data) { + return factory(data, [=] { _inner->update(); }); + }, std::move(paused)); + _inner->document()->documentLayout()->registerHandler( + kCustomEmojiFormat, + _customEmojiObject.get()); +} + void InputField::setAdditionalMargin(int margin) { _additionalMargin = margin; QResizeEvent e(size(), size()); @@ -2105,8 +2200,8 @@ void InputField::processFormatting(int insertPosition, int insertEnd) { auto document = _inner->document(); // Apply inserted tags. - auto insertedTagsProcessor = _insertedTagsAreFromMime - ? _tagMimeProcessor.get() + const auto insertedTagsProcessor = _insertedTagsAreFromMime + ? _tagMimeProcessor : nullptr; const auto breakTagOnNotLetterTill = ProcessInsertedTags( _st, @@ -2176,6 +2271,7 @@ void InputField::processFormatting(int insertPosition, int insertEnd) { action.customEmojiText = fragmentText; action.customEmojiLink = format.property( kCustomEmojiLink).toString(); + break; } const auto with = format.property(kInstantReplaceWithId); @@ -2193,6 +2289,15 @@ void InputField::processFormatting(int insertPosition, int insertEnd) { } } + if (format.hasProperty(kCustomEmojiLink) + && !format.property(kCustomEmojiLink).toString().isEmpty()) { + action.type = ActionType::RemoveCustomEmoji; + action.existingTags = format.property(kTagProperty).toString(); + action.intervalStart = fragmentPosition; + action.intervalEnd = fragmentPosition + + fragmentText.size(); + break; + } if (!startTagFound) { startTagFound = true; auto tagName = format.property(kTagProperty).toString(); @@ -2319,6 +2424,13 @@ void InputField::processFormatting(int insertPosition, int insertEnd) { document, action.intervalStart, action.intervalEnd); + } else if (action.type == ActionType::RemoveCustomEmoji) { + RemoveCustomEmojiTag( + _st, + document, + action.existingTags, + action.intervalStart, + action.intervalEnd); } else if (action.type == ActionType::TildeFont) { auto format = QTextCharFormat(); format.setFont(action.isTilde @@ -2476,6 +2588,11 @@ void InputField::handleContentsChanged() { checkContentHeight(); } startPlaceholderAnimation(); + if (_lastTextWithTags.text.isEmpty()) { + if (const auto object = _customEmojiObject.get()) { + object->clear(); + } + } Integration::Instance().textActionsUpdated(); } @@ -2751,6 +2868,9 @@ TextWithTags InputField::getTextWithAppliedMarkdown() const { void InputField::clear() { _inner->clear(); startPlaceholderAnimation(); + if (const auto object = _customEmojiObject.get()) { + object->clear(); + } } bool InputField::hasFocus() const { @@ -3460,7 +3580,7 @@ QString InputField::CustomEmojiLink(QStringView entityData) { QString InputField::CustomEmojiEntityData(QStringView link) { const auto match = qthelp::regex_match( - "^(\\d+\\.\\d+/\\d+)(\\?|$)", + "^(\\d+\\.\\d+:\\d+/\\d+)(\\?|$)", base::StringViewMid(link, kCustomEmojiTagStart.size())); return match ? match->captured(1) : QString(); } diff --git a/ui/widgets/input_fields.h b/ui/widgets/input_fields.h index 736ea03..3883b58 100644 --- a/ui/widgets/input_fields.h +++ b/ui/widgets/input_fields.h @@ -23,6 +23,10 @@ class QTouchEvent; class Painter; +namespace Ui::Text { +class CustomEmoji; +} // namespace Ui::Text + namespace Ui { const auto kClearFormatSequence = QKeySequence("ctrl+shift+n"); @@ -31,6 +35,10 @@ const auto kMonospaceSequence = QKeySequence("ctrl+shift+m"); const auto kEditLinkSequence = QKeySequence("ctrl+k"); const auto kSpoilerSequence = QKeySequence("ctrl+shift+p"); +using CustomEmojiFactory = Fn( + QStringView, + Fn)>; + class PopupMenu; void InsertEmojiAtCursor(QTextCursor cursor, EmojiPtr emoji); @@ -149,7 +157,10 @@ class CustomEmojiObject : public QObject, public QTextObjectInterface { Q_INTERFACES(QTextObjectInterface) public: - explicit CustomEmojiObject(QObject *parent); + using Factory = Fn(QStringView)>; + + CustomEmojiObject(Factory factory, Fn paused); + ~CustomEmojiObject(); QSizeF intrinsicSize( QTextDocument *doc, @@ -162,6 +173,15 @@ public: int posInDocument, const QTextFormat &format) override; + void setNow(crl::time now); + void clear(); + +private: + Factory _factory; + Fn _paused; + base::flat_map> _emoji; + crl::time _now = 0; + }; class InputField : public RpWidget { @@ -245,12 +265,10 @@ public: // If you need to make some preparations of tags before putting them to QMimeData // (and then to clipboard or to drag-n-drop object), here is a strategy for that. - class TagMimeProcessor { - public: - virtual QString tagFromMimeTag(const QString &mimeTag) = 0; - virtual ~TagMimeProcessor() = default; - }; - void setTagMimeProcessor(std::unique_ptr &&processor); + void setTagMimeProcessor(Fn processor); + void setCustomEmojiFactory( + CustomEmojiFactory factory, + Fn paused); struct EditLinkSelection { int from = 0; @@ -528,7 +546,8 @@ private: // before _documentContentsChanges fire. int _emojiSurrogateAmount = 0; - std::unique_ptr _tagMimeProcessor; + Fn _tagMimeProcessor; + std::unique_ptr _customEmojiObject; SubmitSettings _submitSettings = SubmitSettings::Enter; bool _markdownEnabled = false;