diff --git a/Telegram/SourceFiles/chat_helpers/message_field.cpp b/Telegram/SourceFiles/chat_helpers/message_field.cpp index cc6d45745a..fa34ee3e96 100644 --- a/Telegram/SourceFiles/chat_helpers/message_field.cpp +++ b/Telegram/SourceFiles/chat_helpers/message_field.cpp @@ -403,7 +403,8 @@ Fn DefaultEditLinkCallback( std::shared_ptr show, not_null field, - const style::InputField *fieldStyle) { + const style::InputField *fieldStyle, + Fn linkValidator) { const auto weak = base::make_weak(field); return [=]( EditLinkSelection selection, @@ -413,7 +414,8 @@ FncommitMarkdownLinkEdit(selection, text, link); } }; + const auto validateLink = linkValidator + ? linkValidator + : Fn(qthelp::validate_url); show->showBox(Box( EditLinkBox, show, @@ -486,7 +491,7 @@ FnsetEditLinkCallback( - DefaultEditLinkCallback(show, field, args.fieldStyle)); + DefaultEditLinkCallback( + show, + field, + args.fieldStyle, + args.linkValidator)); field->setEditLanguageCallback(DefaultEditLanguageCallback(show)); InitSpellchecker(show, field, args.fieldStyle != nullptr); } diff --git a/Telegram/SourceFiles/chat_helpers/message_field.h b/Telegram/SourceFiles/chat_helpers/message_field.h index 68c036a3cb..feb615692b 100644 --- a/Telegram/SourceFiles/chat_helpers/message_field.h +++ b/Telegram/SourceFiles/chat_helpers/message_field.h @@ -66,7 +66,8 @@ Fn DefaultEditLinkCallback( std::shared_ptr show, not_null field, - const style::InputField *fieldStyle = nullptr); + const style::InputField *fieldStyle = nullptr, + Fn linkValidator = nullptr); Fn save)> DefaultEditLanguageCallback( std::shared_ptr show); @@ -77,6 +78,7 @@ struct MessageFieldHandlersArgs { Fn customEmojiPaused; Fn)> allowPremiumEmoji; const style::InputField *fieldStyle = nullptr; + Fn linkValidator; base::flat_set allowMarkdownTags; }; auto InitMessageFieldHandlers(MessageFieldHandlersArgs &&args) diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp index 4c88142458..31b2338418 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp @@ -2137,8 +2137,8 @@ void State::rebuildTextNodes( }) : std::nullopt); } - appendBlockTextNode(path, LeafKind::BlockCaption); rebuildTextNodes(block.blocks, BlockChildrenContainer(path)); + appendBlockTextNode(path, LeafKind::BlockCaption); break; case BlockKind::List: for (auto j = 0, itemCount = int(block.listItems.size()); diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.cpp new file mode 100644 index 0000000000..a9e5aa3068 --- /dev/null +++ b/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.cpp @@ -0,0 +1,722 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "iv/editor/iv_editor_text_entities.h" + +#include "iv/markdown/iv_markdown_prepare_serialize.h" +#include "iv/markdown/iv_markdown_prepare_links.h" +#include "ui/widgets/fields/input_field.h" + +#include +#include +#include + +namespace Iv::Editor { +namespace { + +struct FormulaReplacement { + int offset = 0; + int length = 0; + QString source; +}; + +struct TextRange { + int offset = 0; + int length = 0; +}; + +[[nodiscard]] bool RangeInsideText( + const QString &text, + int offset, + int length) { + return (offset >= 0) + && (length >= 0) + && (offset <= text.size()) + && (length <= text.size() - offset); +} + +[[nodiscard]] bool IsFormulaObjectSpan( + const QString &text, + const EntityInText &entity) { + return (entity.length() == 1) + && RangeInsideText(text, entity.offset(), entity.length()) + && (text[entity.offset()] == QChar::ObjectReplacementCharacter); +} + +[[nodiscard]] std::optional +FormulaDataFromEntity(const EntityInText &entity) { + if (entity.type() != EntityType::CustomEmoji) { + return std::nullopt; + } + const auto parsed = Markdown::ParseInlineTextObjectEntity(entity.data()); + if (!parsed || parsed->kind != Markdown::InlineTextObjectKind::Formula) { + return std::nullopt; + } + const auto formula = std::get_if( + &parsed->data); + return formula ? std::make_optional(*formula) : std::nullopt; +} + +[[nodiscard]] QString EditorSourceForFormula( + const Markdown::InlineTextObjectFormulaData &formula) { + const auto stripDelimiters = [](QString result) { + result = result.trimmed(); + if (result.size() >= 2 + && result.front() == QChar('$') + && result.back() == QChar('$')) { + result = result.mid(1, result.size() - 2).trimmed(); + } + return result; + }; + if (!formula.trimmedTex.isEmpty()) { + return Markdown::InlineFormulaCopySource( + stripDelimiters(formula.trimmedTex)); + } + return Markdown::InlineFormulaCopySource( + stripDelimiters(formula.copySource)); +} + +[[nodiscard]] std::optional AdjustEntityForReplacement( + const EntityInText &entity, + int from, + int oldLength, + int newLength) { + const auto till = from + oldLength; + const auto delta = newLength - oldLength; + const auto begin = entity.offset(); + const auto end = begin + entity.length(); + + if (end <= from) { + return entity; + } else if (begin >= till) { + return EntityInText( + entity.type(), + begin + delta, + entity.length(), + entity.data()); + } else if (begin <= from && end >= till) { + const auto length = entity.length() + delta; + if (length <= 0) { + return std::nullopt; + } + return EntityInText(entity.type(), begin, length, entity.data()); + } + return std::nullopt; +} + +[[nodiscard]] std::optional AdjustTagForReplacement( + const TextWithTags::Tag &tag, + int from, + int oldLength, + int newLength) { + const auto till = from + oldLength; + const auto delta = newLength - oldLength; + const auto begin = tag.offset; + const auto end = begin + tag.length; + + if (end <= from) { + return tag; + } else if (begin >= till) { + return TextWithTags::Tag{ + .offset = begin + delta, + .length = tag.length, + .id = tag.id, + }; + } else if (begin <= from && end >= till) { + const auto length = tag.length + delta; + if (length <= 0) { + return std::nullopt; + } + return TextWithTags::Tag{ + .offset = begin, + .length = length, + .id = tag.id, + }; + } + return std::nullopt; +} + +[[nodiscard]] QString TagsWithoutIvEditorTags(QStringView tags) { + auto result = QList(); + for (const auto &tag : TextUtilities::SplitTags(tags)) { + if (!Ui::InputField::IsInstantViewEditorTag(tag) + && !tag.startsWith(QChar('#'))) { + result.push_back(tag); + } + } + return TextUtilities::JoinTag(result); +} + +[[nodiscard]] TextWithTags::Tags GenericTagsWithoutIvEditorTags( + const TextWithTags::Tags &tags) { + auto result = TextWithTags::Tags(); + result.reserve(tags.size()); + for (const auto &tag : tags) { + auto filtered = TagsWithoutIvEditorTags(tag.id); + if (!filtered.isEmpty()) { + result.push_back({ + .offset = tag.offset, + .length = tag.length, + .id = filtered, + }); + } + } + return result; +} + +[[nodiscard]] bool IsValidAnchorEntity(const EntityInText &entity) { + if (entity.type() != EntityType::CustomUrl + || !Ui::InputField::IsInstantViewAnchorLink(entity.data())) { + return false; + } + return !Markdown::NormalizeFragmentId(entity.data().mid(1)).isEmpty(); +} + +void SortTags(TextWithTags::Tags *tags); + +void AppendIvEntityTags( + TextWithTags::Tags *tags, + const EntitiesInText &entities) { + for (const auto &entity : entities) { + if (entity.length() <= 0) { + continue; + } + switch (entity.type()) { + case EntityType::Subscript: + tags->push_back({ + .offset = entity.offset(), + .length = entity.length(), + .id = Ui::InputField::kTagIvSubscript, + }); + break; + case EntityType::Superscript: + tags->push_back({ + .offset = entity.offset(), + .length = entity.length(), + .id = Ui::InputField::kTagIvSuperscript, + }); + break; + case EntityType::Marked: + tags->push_back({ + .offset = entity.offset(), + .length = entity.length(), + .id = Ui::InputField::kTagIvMarked, + }); + break; + case EntityType::CustomUrl: + if (IsValidAnchorEntity(entity)) { + tags->push_back({ + .offset = entity.offset(), + .length = entity.length(), + .id = entity.data(), + }); + } + break; + default: + break; + } + } +} + +void OverlayTag( + TextWithTags::Tags *tags, + const TextWithTags::Tag &overlay, + const QString &text) { + if (overlay.id.isEmpty() + || overlay.length <= 0 + || !RangeInsideText(text, overlay.offset, overlay.length)) { + return; + } + const auto from = overlay.offset; + const auto till = from + overlay.length; + auto coveredTill = from; + auto result = TextWithTags::Tags(); + result.reserve(tags->size() + 3); + + for (const auto &tag : *tags) { + const auto tagFrom = tag.offset; + const auto tagTill = tag.offset + tag.length; + if (tagTill <= from) { + result.push_back(tag); + continue; + } else if (tagFrom >= till) { + if (coveredTill < till) { + result.push_back({ + .offset = coveredTill, + .length = till - coveredTill, + .id = overlay.id, + }); + coveredTill = till; + } + result.push_back(tag); + continue; + } + if (tagFrom > coveredTill) { + result.push_back({ + .offset = coveredTill, + .length = tagFrom - coveredTill, + .id = overlay.id, + }); + coveredTill = tagFrom; + } + if (tagFrom < from) { + result.push_back({ + .offset = tagFrom, + .length = from - tagFrom, + .id = tag.id, + }); + } + const auto middleFrom = std::max(tagFrom, from); + const auto middleTill = std::min(tagTill, till); + if (middleFrom < middleTill) { + result.push_back({ + .offset = middleFrom, + .length = middleTill - middleFrom, + .id = TextUtilities::TagWithAdded(tag.id, overlay.id), + }); + coveredTill = middleTill; + } + if (tagTill > till) { + result.push_back({ + .offset = till, + .length = tagTill - till, + .id = tag.id, + }); + } + } + if (coveredTill < till) { + result.push_back({ + .offset = coveredTill, + .length = till - coveredTill, + .id = overlay.id, + }); + } + SortTags(&result); + *tags = TextUtilities::SimplifyTags(std::move(result)); +} + +void OverlayTags( + TextWithTags::Tags *tags, + const TextWithTags::Tags &overlays, + const QString &text) { + for (const auto &overlay : overlays) { + OverlayTag(tags, overlay, text); + } +} + +void SubtractRange( + std::vector *ranges, + int from, + int till) { + auto result = std::vector(); + for (const auto &range : *ranges) { + const auto rangeFrom = range.offset; + const auto rangeTill = range.offset + range.length; + if (rangeTill <= from || rangeFrom >= till) { + result.push_back(range); + continue; + } + if (rangeFrom < from) { + result.push_back({ + .offset = rangeFrom, + .length = from - rangeFrom, + }); + } + if (rangeTill > till) { + result.push_back({ + .offset = till, + .length = rangeTill - till, + }); + } + } + *ranges = std::move(result); +} + +void RemoveRangesFromTags( + TextWithTags::Tags *tags, + const TextWithTags::Tags &removed) { + auto result = TextWithTags::Tags(); + for (const auto &tag : *tags) { + auto ranges = std::vector{ { + .offset = tag.offset, + .length = tag.length, + } }; + for (const auto &remove : removed) { + SubtractRange( + &ranges, + remove.offset, + remove.offset + remove.length); + } + for (const auto &range : ranges) { + if (range.length > 0) { + result.push_back({ + .offset = range.offset, + .length = range.length, + .id = tag.id, + }); + } + } + } + SortTags(&result); + *tags = TextUtilities::SimplifyTags(std::move(result)); +} + +void SortTags(TextWithTags::Tags *tags) { + std::sort(tags->begin(), tags->end(), [](const auto &a, const auto &b) { + if (a.offset != b.offset) { + return a.offset < b.offset; + } else if (a.length != b.length) { + return a.length < b.length; + } + return a.id < b.id; + }); +} + +[[nodiscard]] bool TagContains(QStringView tags, QStringView tagId) { + return TextUtilities::SplitTags(tags).contains(tagId); +} + +[[nodiscard]] bool TagContainsOtherThan(QStringView tags, QStringView tagId) { + for (const auto &tag : TextUtilities::SplitTags(tags)) { + if (tag != tagId) { + return true; + } + } + return false; +} + +void MergeRanges(std::vector *ranges); + +[[nodiscard]] std::vector RangesContainingTagId( + const TextWithTags::Tags &tags, + QStringView tagId, + const QString &text) { + auto result = std::vector(); + result.reserve(tags.size()); + for (const auto &tag : tags) { + if (tag.length <= 0 + || !RangeInsideText(text, tag.offset, tag.length) + || !TagContains(tag.id, tagId)) { + continue; + } + result.push_back({ + .offset = tag.offset, + .length = tag.length, + }); + } + return result; +} + +[[nodiscard]] EntitiesInText ValidAnchorEntitiesFromTags( + const TextWithTags::Tags &tags, + const QString &text) { + auto result = EntitiesInText(); + result.reserve(tags.size()); + for (const auto &tag : tags) { + if (tag.length <= 0 || !RangeInsideText(text, tag.offset, tag.length)) { + continue; + } + for (const auto &single : TextUtilities::SplitTags(tag.id)) { + if (!Ui::InputField::IsInstantViewAnchorLink(single)) { + continue; + } + auto link = single.toString(); + if (Markdown::NormalizeFragmentId(link).isEmpty()) { + continue; + } + result.push_back(EntityInText( + EntityType::CustomUrl, + tag.offset, + tag.length, + link)); + } + } + return result; +} + +[[nodiscard]] std::vector MathRangesWithoutIntersections( + const TextWithTags::Tags &tags, + const QString &text) { + auto result = std::vector(); + for (const auto &math : tags) { + if (math.length <= 0 + || !RangeInsideText(text, math.offset, math.length) + || !TagContains(math.id, Ui::InputField::kTagIvMath)) { + continue; + } + auto ranges = std::vector{ { + .offset = math.offset, + .length = math.length, + } }; + for (const auto &other : tags) { + if (other.length <= 0 + || !RangeInsideText(text, other.offset, other.length) + || !TagContainsOtherThan( + other.id, + Ui::InputField::kTagIvMath)) { + continue; + } + SubtractRange( + &ranges, + other.offset, + other.offset + other.length); + } + result.insert(result.end(), ranges.begin(), ranges.end()); + } + MergeRanges(&result); + return result; +} + +void AppendEntitiesForTagId( + EntitiesInText *entities, + const TextWithTags::Tags &tags, + QStringView tagId, + EntityType type, + const QString &text) { + for (const auto &range : RangesContainingTagId(tags, tagId, text)) { + entities->push_back(EntityInText(type, range.offset, range.length)); + } +} + +void MergeRanges(std::vector *ranges) { + std::sort(ranges->begin(), ranges->end(), [](const auto &a, const auto &b) { + if (a.offset != b.offset) { + return a.offset < b.offset; + } + return a.length < b.length; + }); + + auto result = std::vector(); + result.reserve(ranges->size()); + for (const auto &range : *ranges) { + if (result.empty()) { + result.push_back(range); + continue; + } + auto &last = result.back(); + const auto lastEnd = last.offset + last.length; + const auto rangeEnd = range.offset + range.length; + if (range.offset > lastEnd) { + result.push_back(range); + } else if (rangeEnd > lastEnd) { + last.length = rangeEnd - last.offset; + } + } + *ranges = std::move(result); +} + +void SortEntities(EntitiesInText *entities) { + std::sort( + entities->begin(), + entities->end(), + [](const auto &a, const auto &b) { + if (a.offset() != b.offset()) { + return a.offset() < b.offset(); + } else if (a.length() != b.length()) { + return a.length() < b.length(); + } else if (a.type() != b.type()) { + return int(a.type()) < int(b.type()); + } + return a.data() < b.data(); + }); +} + +} // namespace + +RichTextEditorConversion ConvertRichTextToEditorTags(TextWithEntities text) { + auto formulas = std::vector(); + auto entities = EntitiesInText(); + entities.reserve(text.entities.size()); + + for (const auto &entity : text.entities) { + const auto formula = FormulaDataFromEntity(entity); + if (formula && IsFormulaObjectSpan(text.text, entity)) { + formulas.push_back({ + .offset = entity.offset(), + .length = entity.length(), + .source = EditorSourceForFormula(*formula), + }); + } else { + entities.push_back(entity); + } + } + + std::sort( + formulas.begin(), + formulas.end(), + [](const auto &a, const auto &b) { + return a.offset > b.offset; + }); + + auto mathTags = TextWithTags::Tags(); + auto replacements = std::vector(); + replacements.reserve(formulas.size()); + + for (const auto &formula : formulas) { + const auto newLength = formula.source.size(); + + for (auto i = entities.begin(); i != entities.end();) { + if (const auto adjusted = AdjustEntityForReplacement( + *i, + formula.offset, + formula.length, + newLength)) { + *i++ = *adjusted; + } else { + i = entities.erase(i); + } + } + for (auto i = mathTags.begin(); i != mathTags.end();) { + if (const auto adjusted = AdjustTagForReplacement( + *i, + formula.offset, + formula.length, + newLength)) { + *i++ = *adjusted; + } else { + i = mathTags.erase(i); + } + } + + text.text.replace(formula.offset, formula.length, formula.source); + if (newLength > 0) { + mathTags.push_back({ + .offset = formula.offset, + .length = newLength, + .id = Ui::InputField::kTagIvMath, + }); + } + replacements.push_back({ + .richOffset = formula.offset, + .richLength = formula.length, + .editorLength = newLength, + }); + } + + auto tags = TextUtilities::ConvertEntitiesToTextTags(entities); + auto ivTags = TextWithTags::Tags(); + AppendIvEntityTags(&ivTags, entities); + RemoveRangesFromTags(&tags, mathTags); + RemoveRangesFromTags(&ivTags, mathTags); + OverlayTags(&tags, ivTags, text.text); + OverlayTags(&tags, mathTags, text.text); + SortTags(&tags); + tags = TextUtilities::SimplifyTags(tags); + + std::sort( + replacements.begin(), + replacements.end(), + [](const auto &a, const auto &b) { + return a.richOffset < b.richOffset; + }); + + return { + .text = { text.text, tags }, + .replacements = replacements, + }; +} + +int MapRichTextOffsetToEditorOffset( + const std::vector &replacements, + int offset) { + auto delta = 0; + for (const auto &replacement : replacements) { + if (replacement.richLength <= 0) { + continue; + } + const auto richStart = replacement.richOffset; + const auto richEnd = richStart + replacement.richLength; + const auto editorStart = richStart + delta; + if (offset < richStart) { + break; + } else if (offset <= richEnd) { + return editorStart + + ((offset == richEnd) ? replacement.editorLength : 0); + } + delta += replacement.editorLength - replacement.richLength; + } + return offset + delta; +} + +TextWithEntities ConvertEditorTagsToRichText(TextWithTags text) { + auto entities = TextUtilities::ConvertTextTagsToEntities( + GenericTagsWithoutIvEditorTags(text.tags)); + AppendEntitiesForTagId( + &entities, + text.tags, + Ui::InputField::kTagIvMarked, + EntityType::Marked, + text.text); + AppendEntitiesForTagId( + &entities, + text.tags, + Ui::InputField::kTagIvSubscript, + EntityType::Subscript, + text.text); + AppendEntitiesForTagId( + &entities, + text.tags, + Ui::InputField::kTagIvSuperscript, + EntityType::Superscript, + text.text); + for (const auto &entity : ValidAnchorEntitiesFromTags( + text.tags, + text.text)) { + entities.push_back(entity); + } + + auto mathRanges = MathRangesWithoutIntersections(text.tags, text.text); + for (auto i = mathRanges.rbegin(); i != mathRanges.rend(); ++i) { + const auto source = text.text.mid(i->offset, i->length); + auto trimmedSource = source.trimmed(); + if (trimmedSource.size() >= 2 + && trimmedSource.front() == QChar('$') + && trimmedSource.back() == QChar('$')) { + trimmedSource = trimmedSource + .mid(1, trimmedSource.size() - 2) + .trimmed(); + } + if (trimmedSource.isEmpty()) { + continue; + } + const auto entityData = Markdown::SerializeInlineTextObjectEntity({ + .kind = Markdown::InlineTextObjectKind::Formula, + .data = Markdown::InlineTextObjectFormulaData{ + .copySource = Markdown::InlineFormulaCopySource(trimmedSource), + .trimmedTex = trimmedSource, + }, + }); + for (auto j = entities.begin(); j != entities.end();) { + if (const auto adjusted = AdjustEntityForReplacement( + *j, + i->offset, + i->length, + 1)) { + *j++ = *adjusted; + } else { + j = entities.erase(j); + } + } + text.text.replace( + i->offset, + i->length, + QString(QChar::ObjectReplacementCharacter)); + entities.push_back(EntityInText( + EntityType::CustomEmoji, + i->offset, + 1, + entityData)); + } + + SortEntities(&entities); + return { + .text = text.text, + .entities = entities, + }; +} + +} // namespace Iv::Editor diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.h b/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.h new file mode 100644 index 0000000000..ef9039afed --- /dev/null +++ b/Telegram/SourceFiles/iv/editor/iv_editor_text_entities.h @@ -0,0 +1,34 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "ui/text/text_entity.h" + +#include + +namespace Iv::Editor { + +struct RichTextEditorOffsetReplacement { + int richOffset = 0; + int richLength = 0; + int editorLength = 0; +}; + +struct RichTextEditorConversion { + TextWithTags text; + std::vector replacements; +}; + +[[nodiscard]] RichTextEditorConversion ConvertRichTextToEditorTags( + TextWithEntities text); +[[nodiscard]] int MapRichTextOffsetToEditorOffset( + const std::vector &replacements, + int offset); +[[nodiscard]] TextWithEntities ConvertEditorTagsToRichText(TextWithTags text); + +} // namespace Iv::Editor diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp index 75b68f7c51..c051f435e8 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp @@ -7,12 +7,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/editor/iv_editor_widget.h" +#include "base/qthelp_url.h" #include "base/qt/qt_common_adapters.h" #include "chat_helpers/message_field.h" #include "data/data_msg_id.h" #include "data/data_types.h" #include "data/stickers/data_custom_emoji.h" +#include "iv/editor/iv_editor_text_entities.h" #include "iv/markdown/iv_markdown_article_paint.h" +#include "iv/markdown/iv_markdown_prepare_links.h" #include "iv/markdown/iv_markdown_prepare_native_richtext.h" #include "spellcheck/spellcheck_highlight_syntax.h" #include "ui/chat/chat_style.h" @@ -156,6 +159,25 @@ void EnableQTextEditLineMetrics(style::Markdown &style) { return false; } +[[nodiscard]] QString ValidateInstantViewEditorLink(QString link) { + const auto normal = qthelp::validate_url(link); + if (!normal.isEmpty()) { + return normal; + } + link = link.trimmed(); + const auto hasPayload = [&](const QString &prefix) { + return link.startsWith(prefix) + && !link.mid(prefix.size()).trimmed().isEmpty(); + }; + if (hasPayload(u"mailto:"_q) + || hasPayload(u"tel:"_q) + || (link.startsWith(u"#"_q) + && !Markdown::NormalizeFragmentId(link).isEmpty())) { + return link; + } + return QString(); +} + [[nodiscard]] bool ImeEventProducesInput( const QInputMethodEvent &e, const QTextCursor &cursor) { @@ -1479,6 +1501,9 @@ const Widget::CachedInlineFieldStyle &Widget::inlineFieldStyleFor( auto key = inlineFieldStyleKey(data); auto textFg = data.textFg; auto ownedTextFg = std::shared_ptr(); + auto ownedTextMarkBg = std::make_shared( + data.textMarkBg); + auto textMarkBg = ownedTextMarkBg->color(); if (_inlineFieldTextColorOverride && data.textFg.get() == _inlineFieldTextColorOverride->color().get()) { ownedTextFg = std::make_shared(data.textFg->c); @@ -1498,6 +1523,7 @@ const Widget::CachedInlineFieldStyle &Widget::inlineFieldStyleFor( : data.textStyle->font; fieldStyle->style.lineHeight = data.lineHeight; fieldStyle->textFg = textFg; + fieldStyle->textMarkBg = textMarkBg; fieldStyle->textAlign = data.align; fieldStyle->placeholderFont = fieldStyle->style.font; fieldStyle->placeholderAlign = data.align; @@ -1505,6 +1531,7 @@ const Widget::CachedInlineFieldStyle &Widget::inlineFieldStyleFor( .key = key, .style = std::move(fieldStyle), .ownedTextFg = std::move(ownedTextFg), + .ownedTextMarkBg = std::move(ownedTextMarkBg), }); return _fieldStyles.back(); } @@ -1558,6 +1585,9 @@ Widget::InlineFieldStyleData Widget::normalizedInlineFieldStyle( .textFg = _inlineFieldTextColorOverride ? _inlineFieldTextColorOverride->color() : (valid ? leafStyle.textColor : _articleStyle->textColor), + .textMarkBg = valid + ? leafStyle.markBg + : _articleStyle->textPalette.markBg->c, .align = valid ? leafStyle.align : style::al_left, .italic = valid ? leafStyle.italic : false, }; @@ -1574,6 +1604,7 @@ Widget::InlineFieldStyleKey Widget::inlineFieldStyleKey( : textStyle->font, .lineHeight = data.lineHeight, .textFg = data.textFg, + .textMarkBg = data.textMarkBg, .align = data.align, }; } @@ -1601,6 +1632,7 @@ void Widget::setupInlineField() { not_null emoji) { return Data::AllowEmojiWithoutPremium(peer, emoji); }; + _field->setInstantViewEditorTagsEnabled(true); InitMessageFieldHandlers({ .session = &_controller->session(), .show = _controller->uiShow(), @@ -1611,11 +1643,13 @@ void Widget::setupInlineField() { }, .allowPremiumEmoji = allowPremiumEmoji, .fieldStyle = &_field->st(), + .linkValidator = ValidateInstantViewEditorLink, }); _field->setMimeDataHook(WrappedMessageFieldMimeHook( Ui::InputField::MimeDataHook(), _field.get())); } else { + _field->setInstantViewEditorTagsEnabled(false); _field->setInstantReplacesEnabled( rpl::single(false), rpl::single(false)); @@ -1737,24 +1771,30 @@ void Widget::activateTextOrdinal( ensureInlineFieldForSegment(segmentIndex); refreshInlineFieldPlaceholder(); _settingField = true; + auto cursorSelectionFrom = selectionFrom; + auto cursorSelectionTo = selectionTo; if (_state->activeFieldMode() == State::FieldMode::Raw) { _field->setTextWithTags( { _state->activeRawText(), {} }, Ui::InputField::HistoryAction::Clear); _article->clearTextLeafHeightOverride(); } else { - const auto activeText = _state->activeText(); + const auto activeText = ConvertRichTextToEditorTags( + _state->activeText()); _field->setTextWithTags( - { - activeText.text, - TextUtilities::ConvertEntitiesToTextTags(activeText.entities), - }, + activeText.text, Ui::InputField::HistoryAction::Clear); + cursorSelectionFrom = MapRichTextOffsetToEditorOffset( + activeText.replacements, + selectionFrom); + cursorSelectionTo = MapRichTextOffsetToEditorOffset( + activeText.replacements, + selectionTo); } auto cursor = _field->textCursor(); const auto size = int(_field->getLastText().size()); - const auto from = std::clamp(selectionFrom, 0, size); - const auto to = std::clamp(selectionTo, 0, size); + const auto from = std::clamp(cursorSelectionFrom, 0, size); + const auto to = std::clamp(cursorSelectionTo, 0, size); cursor.setPosition(from); if (to != from) { cursor.setPosition(to, QTextCursor::KeepAnchor); @@ -1784,10 +1824,7 @@ void Widget::applyFieldTextToState() { return; } const auto text = _field->getTextWithAppliedMarkdown(); - _state->applyActiveText({ - .text = text.text, - .entities = TextUtilities::ConvertTextTagsToEntities(text.tags), - }); + _state->applyActiveText(ConvertEditorTagsToRichText(text)); } void Widget::hideInlineField() { diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h index bece2f6dd9..01c2abba9d 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h @@ -85,6 +85,7 @@ private: const style::TextStyle *textStyle = nullptr; int lineHeight = 0; style::color textFg; + QColor textMarkBg; style::align align = style::al_left; bool italic = false; }; @@ -93,6 +94,7 @@ private: style::font font; int lineHeight = 0; style::color textFg; + QColor textMarkBg; style::align align = style::al_left; friend inline bool operator==( @@ -101,6 +103,7 @@ private: return (a.font == b.font) && (a.lineHeight == b.lineHeight) && (a.textFg == b.textFg) + && (a.textMarkBg == b.textMarkBg) && (a.align == b.align); } @@ -115,6 +118,7 @@ private: InlineFieldStyleKey key; std::shared_ptr style; std::shared_ptr ownedTextFg; + std::shared_ptr ownedTextMarkBg; }; enum class DragSelectionMode { diff --git a/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp b/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp index 6eb7764081..72954c9332 100644 --- a/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp +++ b/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp @@ -38,6 +38,17 @@ using TaskState = RichPage::TaskState; constexpr auto kDefaultMapWidth = 400; constexpr auto kDefaultMapHeight = 200; +constexpr auto kNoEntityIndex = -1; + +[[nodiscard]] QString FormulaTexFromSource(QString source) { + source = source.trimmed(); + if (source.size() >= 2 + && source.front() == QChar('$') + && source.back() == QChar('$')) { + source = source.mid(1, source.size() - 2).trimmed(); + } + return source; +} struct SerializeContext { not_null session; @@ -250,24 +261,42 @@ struct SerializeContext { return result; } -[[nodiscard]] const EntityInText *FindOuterEntityAt( +[[nodiscard]] bool SkipEntityForRange( + const std::vector &entities, + int index, + int skipIndex) { + if (index == skipIndex) { + return true; + } else if (skipIndex == kNoEntityIndex) { + return false; + } + const auto &entity = entities[index]; + const auto &skip = entities[skipIndex]; + return (index < skipIndex) + && (entity.offset() == skip.offset()) + && (entity.length() == skip.length()); +} + +[[nodiscard]] int FindOuterEntityAt( const std::vector &entities, int position, int till, - const EntityInText *skip) { - for (const auto &entity : entities) { - if (&entity == skip) { + int skipIndex) { + const auto count = int(entities.size()); + for (auto index = 0; index != count; ++index) { + const auto &entity = entities[index]; + if (SkipEntityForRange(entities, index, skipIndex)) { continue; } if (entity.offset() == position && entity.offset() + entity.length() <= till) { - return &entity; + return index; } if (entity.offset() > position) { break; } } - return nullptr; + return kNoEntityIndex; } [[nodiscard]] std::optional SerializeRichTextRange( @@ -276,13 +305,14 @@ struct SerializeContext { int from, int till, SerializeContext *context, - const EntityInText *skip); + int skipIndex); [[nodiscard]] std::optional SerializeRichTextEntity( const QString &text, const std::vector &entities, - const EntityInText &entity, + int entityIndex, SerializeContext *context) { + const auto &entity = entities[entityIndex]; const auto from = entity.offset(); const auto length = entity.length(); const auto segment = text.mid(from, length); @@ -292,7 +322,7 @@ struct SerializeContext { from, from + length, context, - &entity); + entityIndex); if (!inner) { return std::nullopt; } @@ -362,9 +392,9 @@ struct SerializeContext { if (!formula) { return std::nullopt; } - const auto source = !formula->copySource.isEmpty() - ? formula->copySource - : formula->trimmedTex; + const auto source = !formula->trimmedTex.isEmpty() + ? formula->trimmedTex + : FormulaTexFromSource(formula->copySource); return source.isEmpty() ? std::optional( MakePlainRichText(segment)) @@ -446,13 +476,15 @@ struct SerializeContext { int from, int till, SerializeContext *context, - const EntityInText *skip) { + int skipIndex) { auto parts = QVector(); auto position = from; while (position < till) { auto nextEntityStart = till; - for (const auto &entity : entities) { - if (&entity == skip) { + const auto count = int(entities.size()); + for (auto index = 0; index != count; ++index) { + const auto &entity = entities[index]; + if (SkipEntityForRange(entities, index, skipIndex)) { continue; } if (entity.offset() >= position @@ -471,8 +503,8 @@ struct SerializeContext { entities, position, till, - skip); - if (!entity) { + skipIndex); + if (entity == kNoEntityIndex) { parts.push_back(MakePlainRichText(text.mid(position, 1))); ++position; continue; @@ -480,13 +512,14 @@ struct SerializeContext { const auto wrapped = SerializeRichTextEntity( text, entities, - *entity, + entity, context); if (!wrapped) { return std::nullopt; } parts.push_back(*wrapped); - position = entity->offset() + entity->length(); + const auto &wrappedEntity = entities[entity]; + position = wrappedEntity.offset() + wrappedEntity.length(); } return JoinRichTextParts(std::move(parts)); } @@ -502,7 +535,7 @@ struct SerializeContext { 0, text.text.text.size(), context, - nullptr); + kNoEntityIndex); if (!result) { return std::nullopt; } diff --git a/Telegram/SourceFiles/iv/iv_rich_page.cpp b/Telegram/SourceFiles/iv/iv_rich_page.cpp index c19935dd1d..236cdbdba1 100644 --- a/Telegram/SourceFiles/iv/iv_rich_page.cpp +++ b/Telegram/SourceFiles/iv/iv_rich_page.cpp @@ -43,6 +43,16 @@ using TableRow = RichPage::TableRow; using TableVerticalAlignment = RichPage::TableVerticalAlignment; using TaskState = RichPage::TaskState; +[[nodiscard]] QString FormulaTexFromSource(QString source) { + source = source.trimmed(); + if (source.size() >= 2 + && source.front() == QChar('$') + && source.back() == QChar('$')) { + source = source.mid(1, source.size() - 2).trimmed(); + } + return source; +} + const auto PhotoLargeLevels = u"ydxcwmbsa"_q; constexpr auto kDefaultMapWidth = 400; constexpr auto kDefaultMapHeight = 200; @@ -423,12 +433,12 @@ void RememberWebPageMedia( entityData)); return true; }, [&](const MTPDtextMath &data) { - const auto source = qs(data.vsource()); + const auto source = FormulaTexFromSource(qs(data.vsource())); const auto entityData = Markdown::SerializeInlineTextObjectEntity({ .kind = Markdown::InlineTextObjectKind::Formula, .data = Markdown::InlineTextObjectFormulaData{ .copySource = Markdown::InlineFormulaCopySource(source), - .trimmedTex = source.trimmed(), + .trimmedTex = source, }, }); if (entityData.isEmpty()) { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index 53e1c64462..b9596b026c 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -180,6 +180,15 @@ void RestoreRelatedArticleImageStates( return segment.isTextLeaf() || IsDisplayMathSegment(segment); } +[[nodiscard]] QColor MarkBgColorForStyle(const style::Markdown &st) { + auto result = st.textPalette.markBg->c; + result.setAlphaF(result.alphaF() * std::clamp( + st.markBgOpacity, + 0., + 1.)); + return result; +} + template [[nodiscard]] int CompareValues(const T &a, const T &b) { return (a < b) ? -1 : (b < a) ? 1 : 0; @@ -2084,11 +2093,7 @@ void MarkdownArticle::Impl::paint( local.selectionState.segments = &_segments; const auto &paintSt = local.paintMarkdownStyle(st); auto textPalette = paintSt.textPalette; - auto markBg = textPalette.markBg->c; - markBg.setAlphaF(markBg.alphaF() * std::clamp( - paintSt.markBgOpacity, - 0., - 1.)); + auto markBg = MarkBgColorForStyle(paintSt); const auto ownedMarkBg = style::internal::OwnedColor(markBg); textPalette.markBg = ownedMarkBg.color(); const auto &previousTextPalette = p.textPalette(); @@ -2305,6 +2310,7 @@ MarkdownArticleTextLeafStyle MarkdownArticle::Impl::textLeafStyleForSegment( return { .textStyle = &textStyle, .textColor = TextColorForSegment(*segment, st), + .markBg = MarkBgColorForStyle(st), .lineHeight = TextLineHeight(textStyle), .align = segment->align, .italic = segment->block && segment->block->pullquote, @@ -2325,6 +2331,7 @@ MarkdownArticleTextLeafStyle MarkdownArticle::Impl::editableStyleForSegment( return { .textStyle = &st.displayMath.fallbackStyle, .textColor = st.displayMath.fg, + .markBg = MarkBgColorForStyle(st), .lineHeight = TextLineHeight(st.displayMath.fallbackStyle), .align = segment->align, }; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index daaf55c47e..94b527e6f6 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -20,6 +20,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/click_handler.h" #include "ui/painter.h" +#include + #include #include #include @@ -250,6 +252,7 @@ inline bool operator!=( struct MarkdownArticleTextLeafStyle { const style::TextStyle *textStyle = nullptr; style::color textColor; + QColor markBg; int lineHeight = 0; style::align align = style::al_left; bool italic = false; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp index 0034b0bfce..b791162239 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp @@ -1094,20 +1094,16 @@ auto InlineFormulaObjectCache::lookupOrCreate( if (measuredData) { measured = *measuredData; } else { - const auto fallbackAscent = std::max(TextLineAscent(textStyle), 0); - const auto fallbackSize = QSize( - std::max(textStyle.font->width(signature.trimmedTex), 1), - std::max(TextLineHeight(textStyle), 1)); - measured.logicalSize = fallbackSize; - measured.logicalDepth = std::max( - fallbackSize.height() - fallbackAscent, - 0); - measured.exact = InlineFormulaExactMetricsFromLogical( - fallbackSize, - fallbackAscent); - measured.fallbackText = signature.trimmedTex; - measured.success = false; - NormalizeInlineFormulaRasterMetrics(&measured); + if (!_renderer) { + _renderer = std::make_shared(); + } + measured = _renderer->measureFormula({ + .trimmedTex = signature.trimmedTex, + .kind = signature.kind, + .textSize = signature.textSize, + .renderWidthCap = signature.renderWidthCap, + .renderHeightCap = signature.renderHeightCap, + }); measuredData = std::make_shared(measured); } const auto fallbackText = InlineFormulaDisplayFallbackText( diff --git a/Telegram/cmake/td_iv.cmake b/Telegram/cmake/td_iv.cmake index ebe3060523..06190a13fd 100644 --- a/Telegram/cmake/td_iv.cmake +++ b/Telegram/cmake/td_iv.cmake @@ -15,6 +15,8 @@ PRIVATE iv/editor/iv_editor_box.h iv/editor/iv_editor_state.cpp iv/editor/iv_editor_state.h + iv/editor/iv_editor_text_entities.cpp + iv/editor/iv_editor_text_entities.h iv/editor/iv_editor_widget.cpp iv/editor/iv_editor_widget.h diff --git a/Telegram/lib_spellcheck b/Telegram/lib_spellcheck index fbc99868e1..0ebd04ece2 160000 --- a/Telegram/lib_spellcheck +++ b/Telegram/lib_spellcheck @@ -1 +1 @@ -Subproject commit fbc99868e1397136c23d797d3750c9d0a10acdce +Subproject commit 0ebd04ece297494394be409e73ce276b24565175