From 7ba610fd3304ba4005537c480c694d17e7958872 Mon Sep 17 00:00:00 2001 From: John Preston Date: Tue, 5 May 2026 23:12:54 +0400 Subject: [PATCH] Initial attempt to feed IV into the new viewer. --- Telegram/SourceFiles/data/data_session.cpp | 3 + Telegram/SourceFiles/data/data_session.h | 5 + Telegram/SourceFiles/iv/iv.style | 36 + Telegram/SourceFiles/iv/iv_data.cpp | 4 + Telegram/SourceFiles/iv/iv_data.h | 1 + Telegram/SourceFiles/iv/iv_instance.cpp | 385 +++++- .../iv/markdown/iv_markdown_article.cpp | 639 ++++++++- .../iv/markdown/iv_markdown_article.h | 3 + .../iv/markdown/iv_markdown_common.h | 46 + .../iv/markdown/iv_markdown_controller.cpp | 112 +- .../iv/markdown/iv_markdown_controller.h | 24 +- .../iv/markdown/iv_markdown_prepare.cpp | 1160 +++++++++++++++++ .../iv/markdown/iv_markdown_prepare.h | 54 + .../iv/markdown/iv_markdown_view.cpp | 89 +- .../iv/markdown/iv_markdown_view.h | 7 + .../SourceFiles/tests/test_markdown_iv.cpp | 945 ++++++++++++++ 16 files changed, 3462 insertions(+), 51 deletions(-) diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 7d2e394758..ee52c0e597 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -1817,16 +1817,19 @@ void Session::documentLoadFail( void Session::photoLoadProgress(not_null photo) { requestPhotoViewRepaint(photo); + _photoLoadProgress.fire_copy(photo); } void Session::photoLoadDone(not_null photo) { notifyPhotoLayoutChanged(photo); + _photoLoadProgress.fire_copy(photo); } void Session::photoLoadFail( not_null photo, bool started) { notifyPhotoLayoutChanged(photo); + _photoLoadProgress.fire_copy(photo); } void Session::markMediaRead(not_null document) { diff --git a/Telegram/SourceFiles/data/data_session.h b/Telegram/SourceFiles/data/data_session.h index 0134ed09f6..f11957f18e 100644 --- a/Telegram/SourceFiles/data/data_session.h +++ b/Telegram/SourceFiles/data/data_session.h @@ -644,6 +644,10 @@ public: void documentLoadDone(not_null document); void documentLoadFail(not_null document, bool started); + [[nodiscard]] auto photoLoadProgress() const + -> rpl::producer> { + return _photoLoadProgress.events(); + } [[nodiscard]] auto documentLoadProgress() const -> rpl::producer> { return _documentLoadProgress.events(); @@ -1285,6 +1289,7 @@ private: rpl::event_stream> _webpageUpdates; rpl::event_stream> _pollUpdates; rpl::event_stream> _channelDifferenceTooLong; + rpl::event_stream> _photoLoadProgress; rpl::event_stream> _documentLoadProgress; base::flat_set> _suggestToGigagroup; diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index c89a612d5c..7196f4648e 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -79,6 +79,8 @@ MarkdownBlockSkips { quote: pixels; displayMath: pixels; table: pixels; + photo: pixels; + placeholder: pixels; } MarkdownList { indent: pixels; @@ -123,6 +125,18 @@ MarkdownTable { overflowWidth: pixels; overflowFg: color; } +MarkdownPlaceholder { + padding: margins; + minHeight: pixels; + captionSkip: pixels; + labelStyle: TextStyle; +} +MarkdownPhoto { + padding: margins; + captionSkip: pixels; + progressSize: pixels; + progressWidth: pixels; +} MarkdownFailure { label: FlatLabel; width: pixels; @@ -147,6 +161,8 @@ Markdown { rule: MarkdownRule; displayMath: MarkdownDisplayMath; table: MarkdownTable; + placeholder: MarkdownPlaceholder; + photo: MarkdownPhoto; failure: MarkdownFailure; } @@ -176,6 +192,8 @@ defaultMarkdownBlockSkips: MarkdownBlockSkips { quote: 18px; displayMath: 20px; table: 20px; + photo: 20px; + placeholder: 20px; } defaultMarkdownList: MarkdownList { indent: 28px; @@ -220,6 +238,22 @@ defaultMarkdownTable: MarkdownTable { overflowWidth: 4px; overflowFg: windowSubTextFg; } +defaultMarkdownPlaceholderLabelStyle: TextStyle(defaultMarkdownBodyStyle) { + font: font(16px semibold); + lineHeight: 24px; +} +defaultMarkdownPlaceholder: MarkdownPlaceholder { + padding: margins(24px, 28px, 24px, 28px); + minHeight: 124px; + captionSkip: 14px; + labelStyle: defaultMarkdownPlaceholderLabelStyle; +} +defaultMarkdownPhoto: MarkdownPhoto { + padding: margins(4px, 0px, 0px, 0px); + captionSkip: 12px; + progressSize: 36px; + progressWidth: 3px; +} defaultMarkdownFailureLabel: FlatLabel(defaultFlatLabel) { minWidth: 280px; textFg: windowSubTextFg; @@ -268,5 +302,7 @@ defaultMarkdown: Markdown { rule: defaultMarkdownRule; displayMath: defaultMarkdownDisplayMath; table: defaultMarkdownTable; + placeholder: defaultMarkdownPlaceholder; + photo: defaultMarkdownPhoto; failure: defaultMarkdownFailure; } diff --git a/Telegram/SourceFiles/iv/iv_data.cpp b/Telegram/SourceFiles/iv/iv_data.cpp index 8e228ab16d..9e3b4c648d 100644 --- a/Telegram/SourceFiles/iv/iv_data.cpp +++ b/Telegram/SourceFiles/iv/iv_data.cpp @@ -71,6 +71,10 @@ bool Data::partial() const { Data::~Data() = default; +const Source &Data::source() const { + return *_source; +} + void Data::updateCachedViews(int cachedViews) { _source->updatedCachedViews = std::max( _source->updatedCachedViews, diff --git a/Telegram/SourceFiles/iv/iv_data.h b/Telegram/SourceFiles/iv/iv_data.h index cdb2d87089..6bdd3a9834 100644 --- a/Telegram/SourceFiles/iv/iv_data.h +++ b/Telegram/SourceFiles/iv/iv_data.h @@ -44,6 +44,7 @@ public: [[nodiscard]] QString id() const; [[nodiscard]] bool partial() const; + [[nodiscard]] const Source &source() const; void updateCachedViews(int cachedViews); diff --git a/Telegram/SourceFiles/iv/iv_instance.cpp b/Telegram/SourceFiles/iv/iv_instance.cpp index a30abdc396..c076012d40 100644 --- a/Telegram/SourceFiles/iv/iv_instance.cpp +++ b/Telegram/SourceFiles/iv/iv_instance.cpp @@ -31,6 +31,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/markdown/iv_markdown_controller.h" #include "iv/iv_controller.h" #include "iv/iv_data.h" +#include "iv/iv_prepare.h" #include "lang/lang_keys.h" #include "lottie/lottie_common.h" // Lottie::ReadContent. #include "main/main_account.h" @@ -45,6 +46,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/text/text_utilities.h" #include "ui/toast/toast.h" #include "ui/basic_click_handlers.h" +#include "ui/dynamic_image.h" +#include "ui/dynamic_thumbnails.h" #include "webview/webview_data_stream_memory.h" #include "webview/webview_interface.h" #include "window/window_controller.h" @@ -63,6 +66,218 @@ constexpr auto kMaxLoadParts = 5; constexpr auto kKeepLoadingParts = 8; constexpr auto kAllowPageReloadAfter = 3 * crl::time(1000); +enum class CachedPagePhotoImageKind { + Thumbnail, + Full, +}; + +[[nodiscard]] Window::SessionController *CurrentSessionController( + not_null session) { + if (const auto window = Core::App().activeWindow()) { + if (const auto current = window->sessionController(); + current && (¤t->session() == session)) { + return current; + } + } + return nullptr; +} + +class CachedPagePhotoDynamicImage final : public Ui::DynamicImage { +public: + CachedPagePhotoDynamicImage( + std::shared_ptr<::Data::PhotoMedia> media, + not_null photo, + ::Data::FileOrigin origin, + CachedPagePhotoImageKind kind) + : _media(std::move(media)) + , _photo(photo) + , _origin(std::move(origin)) + , _kind(kind) { + } + + [[nodiscard]] std::shared_ptr clone() override { + return std::make_shared( + _media, + _photo, + _origin, + _kind); + } + + [[nodiscard]] QImage image(int size) override { + Q_UNUSED(size); + ensureWanted(); + if (const auto image = resolvedImage()) { + return image->original(); + } + return QImage(); + } + + void subscribeToUpdates(Fn callback) override { + if (!callback) { + _subscription = {}; + return; + } + _subscription = _photo->owner().photoLoadProgress( + ) | rpl::filter([photo = _photo](not_null updated) { + return (updated == photo); + }) | rpl::on_next([callback = std::move(callback)] { + callback(); + }); + } + +private: + void ensureWanted() { + switch (_kind) { + case CachedPagePhotoImageKind::Thumbnail: + _media->wanted(::Data::PhotoSize::Small, _origin); + break; + case CachedPagePhotoImageKind::Full: + _media->wanted(::Data::PhotoSize::Large, _origin); + break; + } + } + + [[nodiscard]] Image *resolvedImage() const { + switch (_kind) { + case CachedPagePhotoImageKind::Full: + if (const auto large = _media->image(::Data::PhotoSize::Large)) { + return large; + } + [[fallthrough]]; + case CachedPagePhotoImageKind::Thumbnail: + if (const auto small = _media->image(::Data::PhotoSize::Small)) { + return small; + } else if (const auto thumbnail = _media->image(::Data::PhotoSize::Thumbnail)) { + return thumbnail; + } + return _media->thumbnailInline(); + } + return nullptr; + } + + const std::shared_ptr<::Data::PhotoMedia> _media; + const not_null _photo; + const ::Data::FileOrigin _origin; + const CachedPagePhotoImageKind _kind; + rpl::lifetime _subscription; + +}; + +class CachedPagePhotoRuntime final : public Markdown::PhotoRuntime { +public: + CachedPagePhotoRuntime( + not_null session, + not_null photo, + ::Data::FileOrigin origin) + : _session(session) + , _photo(photo) + , _origin(std::move(origin)) + , _media(photo->createMediaView()) { + } + + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + Q_UNUSED(size); + _media->wanted(::Data::PhotoSize::Small, _origin); + return std::make_shared( + _media, + _photo, + _origin, + CachedPagePhotoImageKind::Thumbnail); + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + Q_UNUSED(size); + _media->wanted(::Data::PhotoSize::Large, _origin); + return std::make_shared( + _media, + _photo, + _origin, + CachedPagePhotoImageKind::Full); + } + + [[nodiscard]] bool loaded() const override { + _media->wanted(::Data::PhotoSize::Large, _origin); + return _media->loaded(); + } + + [[nodiscard]] bool loading() const override { + _media->wanted(::Data::PhotoSize::Large, _origin); + return _photo->displayLoading(); + } + + [[nodiscard]] double progress() const override { + _media->wanted(::Data::PhotoSize::Large, _origin); + return _media->progress(); + } + + void open(Qt::MouseButton button) const override { + if (button != Qt::LeftButton && button != Qt::MiddleButton) { + return; + } + if (const auto window = Core::App().activeWindow()) { + const auto item = (HistoryItem*)nullptr; + window->openInMediaView({ + CurrentSessionController(_session), + _photo, + item, + MsgId(0), + PeerId(0), + }); + } + } + +private: + const not_null _session; + const not_null _photo; + const ::Data::FileOrigin _origin; + const std::shared_ptr<::Data::PhotoMedia> _media; + +}; + +class CachedPageMediaRuntime final : public Markdown::MediaRuntime { +public: + CachedPageMediaRuntime( + not_null session, + not_null page) + : _session(session) + , _page(page) { + } + + [[nodiscard]] std::shared_ptr resolveInlineImage( + uint64 documentId, + QSize size) const override { + Q_UNUSED(size); + const auto document = _session->data().document(DocumentId(documentId)); + if (document->isNull()) { + return nullptr; + } + return Ui::MakeDocumentThumbnailFit(document, fileOrigin()); + } + + [[nodiscard]] std::shared_ptr resolvePhoto( + uint64 photoId) const override { + const auto photo = _session->data().photo(PhotoId(photoId)); + if (photo->isNull()) { + return nullptr; + } + return std::make_shared( + _session, + photo, + fileOrigin()); + } + +private: + [[nodiscard]] ::Data::FileOrigin fileOrigin() const { + return ::Data::FileOriginWebPage{ _page->url }; + } + + const not_null _session; + const not_null _page; + +}; + } // namespace class Shown final : public base::has_weak_ptr { @@ -119,9 +334,25 @@ private: void prepare(not_null data, const QString &hash); void createController(); + void createMarkdownController( + Markdown::MarkdownArticleContent content, + QString title, + QString initialFragment, + not_null page); - void showWindowed(Prepared result); + void showWindowed(Prepared result, Source source, bool refresh); + void showHtmlWindowed(Prepared result, bool refresh); + void showMarkdownWindowed( + Markdown::MarkdownArticleContent content, + QString title, + QString initialFragment, + not_null page); [[nodiscard]] ShareBoxResult shareBox(ShareBoxDescriptor &&descriptor); + [[nodiscard]] std::shared_ptr createMediaRuntime( + not_null page) const; + [[nodiscard]] bool activateMarkdownMedia( + const Markdown::MediaActivation &activation, + Qt::MouseButton button) const; [[nodiscard]] ::Data::FileOrigin fileOrigin( not_null page) const; @@ -155,6 +386,7 @@ private: std::shared_ptr _show; QString _id; std::unique_ptr _controller; + std::unique_ptr _markdownController; base::flat_map _streams; base::flat_map _files; base::flat_map> _inChannelValues; @@ -221,12 +453,13 @@ Shown::Shown( void Shown::prepare(not_null data, const QString &hash) { const auto weak = base::make_weak(this); + const auto source = data->source(); _preparing = true; const auto id = _id = data->id(); - data->prepare({}, [=](Prepared result) { + data->prepare({}, [=, source = source](Prepared result) { result.hash = hash; - crl::on_main(weak, [=, result = std::move(result)]() mutable { + crl::on_main(weak, [=, source = source, result = std::move(result)]() mutable { result.url = id; if (_id != id || !_preparing) { return; @@ -234,7 +467,7 @@ void Shown::prepare(not_null data, const QString &hash) { _preparing = false; fillChannelJoinedValues(result); fillEmbeds(std::move(result.embeds)); - showWindowed(std::move(result)); + showWindowed(std::move(result), source, false); }); }); } @@ -407,15 +640,133 @@ void Shown::createController() { }, _controller->lifetime()); } -void Shown::showWindowed(Prepared result) { +void Shown::createMarkdownController( + Markdown::MarkdownArticleContent content, + QString title, + QString initialFragment, + not_null page) { + Expects(!_markdownController); + + auto options = Markdown::OpenOptions{ + .initialFragment = std::move(initialFragment), + .activateMedia = [=]( + const Markdown::MediaActivation &activation, + Qt::MouseButton button) { + return activateMarkdownMedia(activation, button); + }, + }; + _markdownController = std::make_unique( + _delegate, + std::move(content), + std::move(title), + nullptr, + std::move(options)); + _markdownController->events() | rpl::on_next([=](Markdown::Event event) { + using FromType = Markdown::Event::Type; + using ToType = Controller::Event::Type; + switch (event.type) { + case FromType::Close: + _events.fire({ .type = ToType::Close }); + break; + case FromType::Quit: + _events.fire({ .type = ToType::Quit }); + break; + case FromType::OpenFile: + break; + } + }, _markdownController->lifetime()); + Q_UNUSED(page); +} + +void Shown::showWindowed(Prepared result, Source source, bool refresh) { + const auto page = _session->data().webpage(result.pageId); + auto native = Markdown::TryPrepareNativeInstantView({ + .source = &source, + .mediaRuntime = createMediaRuntime(page), + }); + if (!native.supported()) { + showHtmlWindowed(std::move(result), refresh); + return; + } + showMarkdownWindowed( + std::move(native.content), + std::move(result.name), + std::move(result.hash), + page); +} + +void Shown::showHtmlWindowed(Prepared result, bool refresh) { + _markdownController = nullptr; + const auto hadController = (_controller != nullptr); if (!_controller) { createController(); } + if (refresh && hadController) { + _controller->update(std::move(result)); + } else { + _controller->show( + _session->local().resolveStorageIdOther(), + std::move(result), + base::duplicate(_inChannelValues)); + } +} - _controller->show( - _session->local().resolveStorageIdOther(), - std::move(result), - base::duplicate(_inChannelValues)); +void Shown::showMarkdownWindowed( + Markdown::MarkdownArticleContent content, + QString title, + QString initialFragment, + not_null page) { + _controller = nullptr; + if (_markdownController) { + auto options = Markdown::OpenOptions{ + .initialFragment = std::move(initialFragment), + .activateMedia = [=]( + const Markdown::MediaActivation &activation, + Qt::MouseButton button) { + return activateMarkdownMedia(activation, button); + }, + }; + _markdownController->update( + std::move(content), + std::move(title), + std::move(options)); + } else { + createMarkdownController( + std::move(content), + std::move(title), + std::move(initialFragment), + page); + } +} + +std::shared_ptr Shown::createMediaRuntime( + not_null page) const { + return std::make_shared(_session, page); +} + +bool Shown::activateMarkdownMedia( + const Markdown::MediaActivation &activation, + Qt::MouseButton button) const { + if (button != Qt::LeftButton && button != Qt::MiddleButton) { + return false; + } + switch (activation.kind) { + case Markdown::MediaActivationKind::None: + return false; + case Markdown::MediaActivationKind::ExternalUrl: + if (activation.url.isEmpty()) { + return false; + } + HiddenUrlClickHandler::Open(activation.url); + return true; + case Markdown::MediaActivationKind::Photo: + if (!activation.photo) { + return false; + } + activation.photo->open(button); + return true; + } + return false; } ::Data::FileOrigin Shown::fileOrigin(not_null page) const { @@ -743,11 +1094,12 @@ bool Shown::showingFrom(not_null session) const { } bool Shown::activeFor(not_null session) const { - return showingFrom(session) && _controller; + return showingFrom(session) && (_controller || _markdownController); } bool Shown::active() const { - return _controller && _controller->active(); + return (_controller && _controller->active()) + || (_markdownController && _markdownController->active()); } void Shown::moveTo(not_null data, QString hash) { @@ -756,16 +1108,15 @@ void Shown::moveTo(not_null data, QString hash) { void Shown::update(not_null data) { const auto weak = base::make_weak(this); + const auto source = data->source(); const auto id = data->id(); - data->prepare({}, [=](Prepared result) { - crl::on_main(weak, [=, result = std::move(result)]() mutable { + data->prepare({}, [=, source = source](Prepared result) { + crl::on_main(weak, [=, source = source, result = std::move(result)]() mutable { result.url = id; fillChannelJoinedValues(result); fillEmbeds(std::move(result.embeds)); - if (_controller) { - _controller->update(std::move(result)); - } + showWindowed(std::move(result), source, true); }); }); } @@ -779,6 +1130,8 @@ void Shown::showJoinedTooltip() { void Shown::minimize() { if (_controller) { _controller->minimize(); + } else if (_markdownController) { + _markdownController->minimize(); } } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index e3febab825..ddb06eed2a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -1,6 +1,7 @@ #include "iv/markdown/iv_markdown_article.h" #include "lang/lang_keys.h" +#include "ui/dynamic_image.h" #include "ui/style/style_core.h" #include "ui/style/style_core_scale.h" #include "ui/text/text_custom_emoji.h" @@ -15,6 +16,7 @@ #include #include "styles/style_iv.h" +#include "styles/palette.h" namespace Iv::Markdown { namespace { @@ -29,6 +31,7 @@ constexpr auto kIvMarkedTextOptions = TextParseOptions{ constexpr auto kCodeTabColumns = 4; constexpr auto kCodeTrailingGuard = 0x2060; constexpr auto kArticleMaxWidth = 32767; +const auto kPhotoCopyLabel = u"Photo"_q; struct LaidOutTableCell { Ui::Text::String leaf; @@ -49,24 +52,30 @@ struct LaidOutTableRow { struct LaidOutBlock { PreparedBlockKind kind = PreparedBlockKind::Paragraph; Ui::Text::String leaf; + Ui::Text::String labelLeaf; Ui::Text::String marker; Ui::Text::String fallbackLeaf; QString copyText; + QString labelText; QString codeLanguage; std::vector children; std::vector tableRows; std::vector tableColumnWidths; QRect outer; QRect textRect; + QRect labelRect; QRect markerRect; QRect contentRect; QRect formulaRect; QRect tableRect; + QRect mediaRect; QRect visibleFormulaRect; QRect visibleTableRect; + QRect visibleMediaRect; QPoint markerCenter; QString anchorId; int textWidth = 0; + int labelWidth = 0; int markerWidth = 0; int headingLevel = 0; ListKind listKind = ListKind::Bullet; @@ -78,6 +87,13 @@ struct LaidOutBlock { bool collapsed = false; bool overflowed = false; int segmentIndex = -1; + int secondarySegmentIndex = -1; + std::shared_ptr photoRuntime; + std::shared_ptr thumbnailImage; + std::shared_ptr fullImage; + MediaActivation activation; + mutable bool thumbnailSubscribed = false; + mutable bool fullSubscribed = false; mutable QImage colorizedFormulaImage; mutable QColor colorizedFormulaColor; mutable QSize colorizedFormulaSize; @@ -88,6 +104,8 @@ enum class SelectableSegmentKind { CodeBlock, DisplayMath, Table, + Placeholder, + Photo, }; struct SelectableSegment { @@ -102,6 +120,7 @@ struct SelectableSegment { int index = -1; int length = 0; int tableSegmentIndex = -1; + int mediaSegmentIndex = -1; [[nodiscard]] bool isTextLeaf() const { return (leaf != nullptr); @@ -299,6 +318,10 @@ void BindLinks( return skips.displayMath; case PreparedBlockKind::Table: return skips.table; + case PreparedBlockKind::Photo: + return skips.photo; + case PreparedBlockKind::Placeholder: + return skips.placeholder; case PreparedBlockKind::Details: return skips.paragraph; } @@ -460,6 +483,56 @@ void BindLinks( return result; } +[[nodiscard]] TextForMimeData CopyTextForMediaBlock( + const QString &label, + const Ui::Text::String &captionLeaf) { + auto result = TextForMimeData::Simple(label); + if (!captionLeaf.toString().isEmpty()) { + result.append(u"\n"_q); + result.append(captionLeaf.toTextForMimeData()); + } + return result; +} + +[[nodiscard]] TextForMimeData CopyTextForPhotoBlock(const LaidOutBlock &block) { + return CopyTextForMediaBlock( + block.copyText.isEmpty() ? kPhotoCopyLabel : block.copyText, + block.leaf); +} + +[[nodiscard]] TextForMimeData CopyTextForPlaceholderBlock( + const LaidOutBlock &block) { + return CopyTextForMediaBlock( + block.labelText.isEmpty() ? block.copyText : block.labelText, + block.leaf); +} + +[[nodiscard]] bool PaintDynamicImage( + Painter &p, + const std::shared_ptr &image, + QRect rect) { + if (!image || rect.isEmpty()) { + return false; + } + if (const auto frame = image->image(std::max(rect.width(), rect.height())); + !frame.isNull()) { + p.drawImage(rect, frame); + return true; + } + return false; +} + +void SubscribeDynamicImage( + const std::shared_ptr &image, + const Fn &repaint, + bool *subscribed) { + if (!image || !repaint || *subscribed) { + return; + } + *subscribed = true; + image->subscribeToUpdates(repaint); +} + [[nodiscard]] const PreparedFormulaSlot *PreparedFormulaFor( const std::vector &formulas, int formulaIndex) { @@ -733,7 +806,37 @@ private: std::map< PreparedFormulaMeasurementSignature, std::shared_ptr, - PreparedFormulaMeasurementSignatureLess> _states; + PreparedFormulaMeasurementSignatureLess> _states; + +}; + +class InlineIvImageObject final : public Ui::Text::CustomEmoji { +public: + InlineIvImageObject( + QString replacementText, + int width, + int height, + std::shared_ptr image, + Fn repaint); + + int width() override; + QString entityData() override; + std::optional vertical( + const style::TextStyle &textStyle) override; + QString replacementText() override; + Ui::Text::CustomEmojiSemantics semantics() override; + void paint(QPainter &p, const Context &context) override; + void unload() override; + bool ready() override; + bool readyInDefaultState() override; + +private: + QString _replacementText; + int _width = 1; + int _height = 1; + const std::shared_ptr _image; + const Fn _repaint; + bool _subscribed = false; }; @@ -1021,6 +1124,97 @@ void InlineFormulaObjectCache::invalidateRasterCache() { } } +InlineIvImageObject::InlineIvImageObject( + QString replacementText, + int width, + int height, + std::shared_ptr image, + Fn repaint) +: _replacementText(std::move(replacementText)) +, _width(std::max(width, 1)) +, _height(std::max(height, 1)) +, _image(std::move(image)) +, _repaint(std::move(repaint)) { +} + +int InlineIvImageObject::width() { + return _width; +} + +QString InlineIvImageObject::entityData() { + return QString(); +} + +std::optional +InlineIvImageObject::vertical(const style::TextStyle &textStyle) { + if (_height > 0) { + return Ui::Text::CustomEmojiVerticalMetrics{ + .ascent = _height, + .descent = 0, + }; + } + const auto ascent = std::max(textStyle.font->ascent, 0); + return Ui::Text::CustomEmojiVerticalMetrics{ + .ascent = ascent, + .descent = std::max(textStyle.font->height - ascent, 0), + }; +} + +QString InlineIvImageObject::replacementText() { + return _replacementText; +} + +Ui::Text::CustomEmojiSemantics InlineIvImageObject::semantics() { + return { + .isEmoji = false, + .isRealCustomEmoji = false, + .exportEntity = false, + .unloadPersistentAnimation = false, + .allowCustomEmojiClick = false, + }; +} + +void InlineIvImageObject::paint(QPainter &p, const Context &context) { + if (_image) { + if (!_subscribed && _repaint) { + _subscribed = true; + _image->subscribeToUpdates(_repaint); + } + if (const auto image = _image->image(std::max(_width, _height)); + !image.isNull()) { + p.drawImage( + QRect(context.position, QSize(_width, _height)), + image); + return; + } + } + if (_replacementText.isEmpty()) { + return; + } + p.save(); + p.setPen(context.textColor); + p.drawText( + QRect(context.position, QSize(_width, _height)), + Qt::AlignCenter | Qt::TextWordWrap, + _replacementText); + p.restore(); +} + +void InlineIvImageObject::unload() { + if (_subscribed && _image) { + _subscribed = false; + _image->subscribeToUpdates(nullptr); + } +} + +bool InlineIvImageObject::ready() { + return true; +} + +bool InlineIvImageObject::readyInDefaultState() { + return true; +} + std::unique_ptr InlineFormulaObjectCache::create( const InlineTextObjectFormulaData &data, const style::TextStyle &textStyle, @@ -1086,28 +1280,52 @@ void SetTextLeaf( const TextWithEntities &text, const std::vector *formulas, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, int minResizeWidth) { *leaf = Ui::Text::String(TextMinResizeWidth(minResizeWidth)); auto context = Ui::Text::MarkedContext(); context.customEmojiFactory = [ formulas, inlineFormulaObjects, + mediaRuntime, &textStyle - ](QStringView data, const Ui::Text::MarkedContext &) { + ]( + QStringView data, + const Ui::Text::MarkedContext &context + ) -> std::unique_ptr { const auto parsed = ParseInlineTextObjectEntity(data.toString()); - if (!parsed || !inlineFormulaObjects) { + if (!parsed) { return std::unique_ptr(); } switch (parsed->kind) { case InlineTextObjectKind::Formula: { + if (!inlineFormulaObjects) { + return std::unique_ptr(); + } const auto formula = std::get_if( &parsed->data); return formula ? inlineFormulaObjects->create(*formula, textStyle, formulas) : std::unique_ptr(); + } break; + case InlineTextObjectKind::IvImage: { + const auto image = std::get_if( + &parsed->data); + if (!image) { + return std::unique_ptr(); + } + const auto resolved = mediaRuntime + ? mediaRuntime->resolveInlineImage( + image->documentId, + QSize(image->width, image->height)) + : nullptr; + return std::make_unique( + image->replacementText, + image->width, + image->height, + std::move(resolved), + context.repaint); } - case InlineTextObjectKind::IvImage: - return std::unique_ptr(); } return std::unique_ptr(); }; @@ -1141,6 +1359,7 @@ void SetTextLeaf( bool header, const std::vector *formulas, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown) { auto result = TableCellLayoutData(); const auto &textStyle = TableCellTextStyle(header, markdown); @@ -1151,6 +1370,7 @@ void SetTextLeaf( prepared.text, formulas, inlineFormulaObjects, + mediaRuntime, TableCellTextMinResizeWidth(textStyle, markdown)); BindLinks(&result.cell.leaf, prepared.links); result.preferredWidth = result.cell.leaf.maxWidth(); @@ -1247,6 +1467,7 @@ void SetTextLeaf( const PreparedBlock &prepared, const std::vector *formulas, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1264,6 +1485,7 @@ void SetTextLeaf( prepared.text, formulas, inlineFormulaObjects, + mediaRuntime, block.textWidth); BindLinks(&block.leaf, prepared.links); @@ -1412,6 +1634,7 @@ void SetTextLeaf( const PreparedBlock &prepared, std::vector *formulas, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1437,6 +1660,7 @@ void SetTextLeaf( preparedRow.header, formulas, inlineFormulaObjects, + mediaRuntime, markdown)); } rows.push_back(std::move(row)); @@ -1520,12 +1744,165 @@ void SetTextLeaf( return block; } +[[nodiscard]] LaidOutBlock LayoutPlaceholderBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Placeholder; + block.anchorId = prepared.anchorId; + block.copyText = prepared.placeholder.copyText; + block.labelText = prepared.placeholder.label; + + const auto &style = markdown.placeholder; + const auto blockWidth = std::max(width, 1); + const auto contentLeft = left + style.padding.left(); + const auto contentWidth = std::max( + blockWidth - style.padding.left() - style.padding.right(), + 1); + block.labelWidth = contentWidth; + block.labelLeaf = Ui::Text::String(TextMinResizeWidth(contentWidth)); + block.labelLeaf.setMarkedText( + style.labelStyle, + TextWithEntities::Simple(block.labelText), + kIvMarkedTextOptions); + const auto labelHeight = std::max( + block.labelLeaf.countHeight(contentWidth, true), + TextLineHeight(style.labelStyle)); + const auto mediaHeight = std::max( + style.minHeight, + labelHeight + style.padding.top() + style.padding.bottom()); + block.mediaRect = QRect(left, top, blockWidth, mediaHeight); + block.visibleMediaRect = block.mediaRect; + block.labelRect = QRect( + contentLeft, + top + std::max((mediaHeight - labelHeight) / 2, 0), + contentWidth, + labelHeight); + + auto bottom = top + mediaHeight; + if (!prepared.text.text.isEmpty()) { + block.textWidth = contentWidth; + SetTextLeaf( + &block.leaf, + markdown.body, + prepared.text, + formulas, + inlineFormulaObjects, + mediaRuntime, + block.textWidth); + BindLinks(&block.leaf, prepared.links); + const auto captionTop = bottom + style.captionSkip; + const auto captionHeight = std::max( + block.leaf.countHeight(block.textWidth, true), + TextLineHeight(markdown.body)); + block.textRect = QRect( + contentLeft, + captionTop, + block.textWidth, + captionHeight); + bottom = captionTop + captionHeight; + } + + block.contentRect = QRect( + left, + top, + blockWidth, + std::max(bottom - top, mediaHeight)); + block.outer = block.contentRect; + return block; +} + +[[nodiscard]] LaidOutBlock LayoutPhotoBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Photo; + block.anchorId = prepared.anchorId; + block.copyText = kPhotoCopyLabel; + + const auto &style = markdown.photo; + const auto blockWidth = std::max(width, 1); + const auto mediaLeft = left + style.padding.left(); + const auto mediaTop = top + style.padding.top(); + const auto mediaWidth = std::max( + blockWidth - style.padding.left() - style.padding.right(), + 1); + const auto aspectWidth = std::max(prepared.photo.width, 1); + const auto aspectHeight = std::max(prepared.photo.height, 1); + const auto mediaHeight = std::max( + int((int64(mediaWidth) * aspectHeight + aspectWidth - 1) / aspectWidth), + 1); + block.mediaRect = QRect(mediaLeft, mediaTop, mediaWidth, mediaHeight); + block.visibleMediaRect = block.mediaRect; + + if (mediaRuntime) { + block.photoRuntime = mediaRuntime->resolvePhoto(prepared.photo.photoId); + } + if (block.photoRuntime) { + const auto size = QSize(mediaWidth, mediaHeight); + block.thumbnailImage = block.photoRuntime->thumbnail(size); + block.fullImage = block.photoRuntime->full(size); + } + if (!prepared.photo.urlOverride.isEmpty()) { + block.activation.kind = MediaActivationKind::ExternalUrl; + block.activation.url = prepared.photo.urlOverride; + } else if (prepared.photo.viewerOpen && block.photoRuntime) { + block.activation.kind = MediaActivationKind::Photo; + block.activation.photo = block.photoRuntime; + } + + auto bottom = mediaTop + mediaHeight + style.padding.bottom(); + if (!prepared.text.text.isEmpty()) { + block.textWidth = mediaWidth; + SetTextLeaf( + &block.leaf, + markdown.body, + prepared.text, + formulas, + inlineFormulaObjects, + mediaRuntime, + block.textWidth); + BindLinks(&block.leaf, prepared.links); + const auto captionTop = bottom + style.captionSkip; + const auto captionHeight = std::max( + block.leaf.countHeight(block.textWidth, true), + TextLineHeight(markdown.body)); + block.textRect = QRect( + mediaLeft, + captionTop, + block.textWidth, + captionHeight); + bottom = captionTop + captionHeight; + } + + block.contentRect = QRect( + mediaLeft, + mediaTop, + mediaWidth, + std::max(bottom - mediaTop, mediaHeight)); + block.outer = QRect(left, top, blockWidth, std::max(bottom - top, mediaHeight)); + return block; +} + [[nodiscard]] LaidOutBlock LayoutBlock( const PreparedBlock &prepared, std::vector *formulas, std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1538,6 +1915,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, std::vector *blocks, const style::Markdown &markdown, int left, @@ -1556,6 +1934,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, left, y, @@ -1574,6 +1953,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1623,6 +2003,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, &block.children, markdown, bodyLeft, @@ -1667,6 +2048,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1702,6 +2084,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, listLeft, y, @@ -1714,6 +2097,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, listLeft, y, @@ -1738,6 +2122,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1770,6 +2155,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, &block.children, markdown, contentLeft, @@ -1798,6 +2184,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1815,6 +2202,7 @@ void SetTextLeaf( prepared.text, formulas, inlineFormulaObjects, + mediaRuntime, block.textWidth); BindLinks(&block.leaf, prepared.links); @@ -1836,6 +2224,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, &block.children, markdown, childLeft, @@ -1858,6 +2247,7 @@ void SetTextLeaf( std::vector *renderedFormulas, MathRenderer *renderer, InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, const style::Markdown &markdown, int left, int top, @@ -1870,6 +2260,7 @@ void SetTextLeaf( prepared, formulas, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1885,6 +2276,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1897,6 +2289,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1910,6 +2303,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1922,6 +2316,27 @@ void SetTextLeaf( prepared, formulas, inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + case PreparedBlockKind::Photo: + return LayoutPhotoBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + case PreparedBlockKind::Placeholder: + return LayoutPlaceholderBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1933,6 +2348,7 @@ void SetTextLeaf( renderedFormulas, renderer, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1943,6 +2359,7 @@ void SetTextLeaf( prepared, formulas, inlineFormulaObjects, + mediaRuntime, markdown, left, top, @@ -1966,6 +2383,7 @@ void CollectSelectableSegments( } for (auto &block : *blocks) { block.segmentIndex = -1; + block.secondarySegmentIndex = -1; switch (block.kind) { case PreparedBlockKind::Paragraph: case PreparedBlockKind::Heading: @@ -2034,6 +2452,33 @@ void CollectSelectableSegments( } } } break; + case PreparedBlockKind::Placeholder: + case PreparedBlockKind::Photo: { + auto segment = SelectableSegment(); + segment.kind = (block.kind == PreparedBlockKind::Photo) + ? SelectableSegmentKind::Photo + : SelectableSegmentKind::Placeholder; + segment.block = █ + segment.outerRect = block.mediaRect; + segment.length = 1; + block.segmentIndex = AddSelectableSegment( + segments, + std::move(segment)); + if (!block.textRect.isEmpty() && !block.leaf.toString().isEmpty()) { + auto textSegment = SelectableSegment(); + textSegment.kind = SelectableSegmentKind::TextLeaf; + textSegment.leaf = &block.leaf; + textSegment.block = █ + textSegment.outerRect = block.textRect; + textSegment.textRect = block.textRect; + textSegment.textWidth = block.textWidth; + textSegment.length = LeafTextLength(block.leaf); + textSegment.mediaSegmentIndex = block.segmentIndex; + block.secondarySegmentIndex = AddSelectableSegment( + segments, + std::move(textSegment)); + } + } break; case PreparedBlockKind::List: case PreparedBlockKind::ListItem: case PreparedBlockKind::Quote: @@ -2630,7 +3075,136 @@ void PaintQuoteBlock( outerWidth, caches, selectionState, - clip.intersected(block.contentRect)); + clip.intersected(block.contentRect)); +} + +void PaintPlaceholderBlock( + Painter &p, + const LaidOutBlock &block, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + const auto visible = clip.intersected(block.visibleMediaRect); + if (!visible.isEmpty()) { + p.save(); + p.setClipRect(visible); + p.fillRect(block.mediaRect, st::windowBgOver->c); + p.setPen(st::windowSubTextFg->c); + PaintTextLeaf( + p, + block.labelLeaf, + caches, + block.labelRect, + block.labelWidth, + visible, + style::al_center); + if (block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex)) { + p.fillRect(block.visibleMediaRect, p.textPalette().selectOverlay); + } + p.restore(); + } + if (!block.textRect.isEmpty()) { + p.setPen(st::defaultMarkdown.textColor->c); + PaintTextLeaf( + p, + block.leaf, + caches, + block.textRect, + block.textWidth, + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.secondarySegmentIndex)); + } +} + +void PaintPhotoProgress( + Painter &p, + QRect rect, + const style::MarkdownPhoto &style, + double progress) { + const auto size = std::min({ + style.progressSize, + rect.width(), + rect.height(), + }); + if (size <= 0) { + return; + } + const auto thickness = std::max(style.progressWidth, 1); + const auto ring = QRect( + rect.center().x() - (size / 2), + rect.center().y() - (size / 2), + size, + size); + auto hq = PainterHighQualityEnabler(p); + p.setBrush(Qt::NoBrush); + p.setPen(QPen(QColor(0, 0, 0, 96), thickness)); + p.drawEllipse(ring); + p.setPen(QPen(st::windowFg->c, thickness)); + p.drawArc( + ring, + 90 * 16, + -int(std::round(360. * 16. * std::clamp(progress, 0., 1.)))); +} + +void PaintPhotoBlock( + Painter &p, + const LaidOutBlock &block, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + const auto visible = clip.intersected(block.visibleMediaRect); + if (!visible.isEmpty()) { + p.save(); + p.setClipRect(visible); + p.fillRect(block.mediaRect, st::windowBgOver->c); + SubscribeDynamicImage( + block.thumbnailImage, + caches.repaint, + &block.thumbnailSubscribed); + SubscribeDynamicImage( + block.fullImage, + caches.repaint, + &block.fullSubscribed); + const auto paintedThumb = PaintDynamicImage(p, block.thumbnailImage, block.mediaRect); + const auto paintedFull = PaintDynamicImage(p, block.fullImage, block.mediaRect); + if (!paintedThumb && !paintedFull) { + p.setPen(st::windowSubTextFg->c); + p.drawText( + block.mediaRect, + Qt::AlignCenter | Qt::TextWordWrap, + block.copyText); + } + if (block.photoRuntime && block.photoRuntime->loading()) { + PaintPhotoProgress( + p, + block.mediaRect, + st::defaultMarkdown.photo, + block.photoRuntime->progress()); + } + if (block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex)) { + p.fillRect(block.visibleMediaRect, p.textPalette().selectOverlay); + } + p.restore(); + } + if (!block.textRect.isEmpty()) { + p.setPen(st::defaultMarkdown.textColor->c); + PaintTextLeaf( + p, + block.leaf, + caches, + block.textRect, + block.textWidth, + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.secondarySegmentIndex)); + } } void PaintBlock( @@ -2756,6 +3330,22 @@ void PaintBlock( selectionState, clip); break; + case PreparedBlockKind::Photo: + PaintPhotoBlock( + p, + block, + caches, + selectionState, + clip); + break; + case PreparedBlockKind::Placeholder: + PaintPlaceholderBlock( + p, + block, + caches, + selectionState, + clip); + break; case PreparedBlockKind::Details: p.setPen(markdown.textColor->c); PaintTextLeaf( @@ -2902,6 +3492,18 @@ void RebuildVisibleSegmentLookup( return (from < till) ? SegmentSpan{ from, till } : SegmentSpan(); } +[[nodiscard]] std::optional PreparedLinkForMediaActivation( + const MediaActivation &activation) { + if (activation.kind != MediaActivationKind::ExternalUrl + || activation.url.isEmpty()) { + return std::nullopt; + } + return PreparedLink{ + .kind = PreparedLinkKind::External, + .target = activation.url, + }; +} + [[nodiscard]] MarkdownArticleHitTestResult HitSegmentBoundary( const SelectableSegment &segment, int offset) { @@ -2958,6 +3560,15 @@ void RebuildVisibleSegmentLookup( auto result = HitSegmentBoundary( segment, after ? SegmentLength(segment) : 0); + if (segment.block) { + result.mediaActivation = segment.block->activation; + if (const auto prepared = PreparedLinkForMediaActivation( + result.mediaActivation)) { + result.preparedLink = prepared; + result.state.link = std::make_shared( + *prepared); + } + } result.direct = true; return result; } @@ -3068,6 +3679,7 @@ public: &_formulaRenders, _renderer.get(), &_inlineFormulaObjects, + _content.mediaRuntime, &blocks, markdown, page.left(), @@ -3254,6 +3866,12 @@ public: auto pieces = std::vector(); for (const auto &segment : _segments) { if (segment.isTextLeaf()) { + if (segment.mediaSegmentIndex >= 0 + && WholeSegmentSelected( + selectionState, + segment.mediaSegmentIndex)) { + continue; + } if (const auto textSelection = TextSelectionForSegment( segment, selectionState); @@ -3369,6 +3987,7 @@ private: &_formulaRenders, _renderer.get(), &_inlineFormulaObjects, + _content.mediaRuntime, &_blocks, markdown, page.left(), @@ -3401,6 +4020,14 @@ private: return segment.block ? CopyTextForTable(*segment.block) : TextForMimeData(); + case SelectableSegmentKind::Placeholder: + return segment.block + ? CopyTextForPlaceholderBlock(*segment.block) + : TextForMimeData(); + case SelectableSegmentKind::Photo: + return segment.block + ? CopyTextForPhotoBlock(*segment.block) + : TextForMimeData(); } return TextForMimeData(); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index e352765f5b..df85a4f5f1 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -1,5 +1,6 @@ #pragma once +#include "iv/markdown/iv_markdown_common.h" #include "iv/markdown/iv_markdown_prepare.h" #include "ui/painter.h" @@ -13,12 +14,14 @@ namespace Iv::Markdown { struct MarkdownArticlePaintCaches { Ui::Text::QuotePaintCache *pre = nullptr; Ui::Text::QuotePaintCache *blockquote = nullptr; + Fn repaint; }; struct MarkdownArticleHitTestResult { int segmentIndex = -1; Ui::Text::StateResult state; std::optional preparedLink; + MediaActivation mediaActivation; int forcedOffset = -1; bool direct = false; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h index b1c40f0d8b..800e057c35 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h @@ -1,18 +1,64 @@ #pragma once +#include #include +#include +#include + +namespace Ui { +class DynamicImage; +} // namespace Ui + namespace Iv { class Delegate; } // namespace Iv namespace Iv::Markdown { +class PhotoRuntime { +public: + virtual ~PhotoRuntime() = default; + + [[nodiscard]] virtual std::shared_ptr thumbnail( + QSize size) const = 0; + [[nodiscard]] virtual std::shared_ptr full( + QSize size) const = 0; + [[nodiscard]] virtual bool loaded() const = 0; + [[nodiscard]] virtual bool loading() const = 0; + [[nodiscard]] virtual double progress() const = 0; + virtual void open(Qt::MouseButton button) const = 0; +}; + +class MediaRuntime { +public: + virtual ~MediaRuntime() = default; + + [[nodiscard]] virtual std::shared_ptr resolveInlineImage( + uint64 documentId, + QSize size) const = 0; + [[nodiscard]] virtual std::shared_ptr resolvePhoto( + uint64 photoId) const = 0; +}; + +enum class MediaActivationKind { + None, + ExternalUrl, + Photo, +}; + +struct MediaActivation { + MediaActivationKind kind = MediaActivationKind::None; + QString url; + std::shared_ptr photo; +}; + struct OpenOptions { QString sourceName; QString sourcePath; QString initialFragment; Iv::Delegate *delegate = nullptr; + std::function activateMedia; }; struct ParseOptions { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp index 89e6a3b21c..a73fd10b66 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp @@ -25,6 +25,17 @@ namespace { constexpr auto kZoomStep = int(10); +[[nodiscard]] OpenOptions PrepareOpenOptions( + OpenOptions options, + not_null delegate, + const QString &title) { + options.delegate = delegate; + if (options.sourceName.isEmpty()) { + options.sourceName = title; + } + return options; +} + struct OpenTarget { QString path; QString fragment; @@ -138,13 +149,26 @@ Controller::Controller( not_null delegate, PreparedDocument document, QString title, - QString sourcePath, - QString initialFragment) + OpenOptions options) : _delegate(delegate) -, _document(std::move(document)) +, _document(std::make_shared(std::move(document))) , _title(std::move(title)) -, _sourcePath(std::move(sourcePath)) -, _initialFragment(std::move(initialFragment)) { +, _renderer(nullptr) +, _options(PrepareOpenOptions(std::move(options), delegate, _title)) { + createWindow(); +} + +Controller::Controller( + not_null delegate, + MarkdownArticleContent content, + QString title, + std::shared_ptr renderer, + OpenOptions options) +: _delegate(delegate) +, _preparedContent(std::move(content)) +, _title(std::move(title)) +, _renderer(renderer ? std::move(renderer) : std::make_shared()) +, _options(PrepareOpenOptions(std::move(options), delegate, _title)) { createWindow(); } @@ -168,10 +192,66 @@ void Controller::activate() { } } +void Controller::update( + MarkdownArticleContent content, + QString title, + OpenOptions options) { + _preparedContent = std::move(content); + _title = std::move(title); + _options = PrepareOpenOptions(std::move(options), _delegate, _title); + if (_window) { + _window->setTitle(_title); + _window->setWindowTitle(_title); + } + createPreview(); + if (_window && _window->isActiveWindow() && _preview) { + _preview->setFocus(); + } +} + +bool Controller::active() const { + return _window && _window->isActiveWindow(); +} + +void Controller::minimize() { + if (_window) { + _window->setWindowState(_window->windowState() | Qt::WindowMinimized); + } +} + void Controller::close() { _events.fire({ Event::Type::Close }); } +void Controller::createPreview() { + if (!_window) { + return; + } + const auto parent = _window->body(); + const auto callback = [=](Event event) { + _events.fire(std::move(event)); + }; + _preview = nullptr; + _preview = _preparedContent + ? CreateMarkdownPreviewWidget( + parent, + std::move(*_preparedContent), + _renderer, + callback, + _options) + : CreateMarkdownPreviewWidget( + parent, + *_document, + callback, + _options); + _preparedContent.reset(); + _preview->setGeometry(parent->rect()); + parent->sizeValue() | rpl::on_next([=](QSize size) { + _preview->resize(size); + }, _preview->lifetime()); + _preview->show(); +} + void Controller::createWindow() { _window = std::make_unique(); const auto window = _window.get(); @@ -188,21 +268,7 @@ void Controller::createWindow() { QPainter(window->body().get()).fillRect(clip, st::windowBg); }, window->body()->lifetime()); - const auto parent = window->body(); - const auto callback = [=](Event event) { - _events.fire(std::move(event)); - }; - _preview = CreateMarkdownPreviewWidget(parent, _document, callback, { - .sourceName = _title, - .sourcePath = _sourcePath, - .initialFragment = _initialFragment, - .delegate = _delegate, - }); - _preview->setGeometry(parent->rect()); - parent->sizeValue() | rpl::on_next([=](QSize size) { - _preview->resize(size); - }, _preview->lifetime()); - _preview->show(); + createPreview(); window->events() | rpl::on_next([=](not_null e) { if (e->type() == QEvent::Close) { @@ -294,14 +360,16 @@ std::unique_ptr TryOpenLocalFile( ).arg(parsed - validated ).arg(target.path)); + auto options = OpenOptions(); + options.sourcePath = std::move(source.path); + options.initialFragment = std::move(target.fragment); return std::make_unique( delegate, std::move(parseResult.document), (parseResult.document.title.trimmed().isEmpty() ? fallbackTitle : parseResult.document.title.trimmed()), - std::move(source.path), - std::move(target.fragment)); + std::move(options)); } } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h index abbbdf8bdf..097b11fd13 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h @@ -1,8 +1,11 @@ #pragma once #include "iv/markdown/iv_markdown_document.h" +#include "iv/markdown/iv_markdown_prepare.h" #include "ui/widgets/rp_window.h" +#include + #include namespace Iv::Markdown { @@ -13,11 +16,20 @@ public: not_null delegate, PreparedDocument document, QString title, - QString sourcePath, - QString initialFragment); + OpenOptions options = {}); + Controller( + not_null delegate, + MarkdownArticleContent content, + QString title, + std::shared_ptr renderer = nullptr, + OpenOptions options = {}); ~Controller(); void activate(); + void update( + MarkdownArticleContent content, + QString title, + OpenOptions options = {}); [[nodiscard]] bool active() const; void minimize(); @@ -31,13 +43,15 @@ public: private: void close(); void createWindow(); + void createPreview(); const not_null _delegate; - PreparedDocument _document; + const std::shared_ptr _document; + std::optional _preparedContent; QString _title; - QString _sourcePath; - QString _initialFragment; + const std::shared_ptr _renderer; + OpenOptions _options; std::unique_ptr _window; std::unique_ptr _preview; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp index 39d3e2fb68..777de491c9 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp @@ -1,6 +1,9 @@ #include "iv/markdown/iv_markdown_prepare.h" #include "iv/markdown/iv_markdown_parse.h" +#include "base/unixtime.h" +#include "iv/iv_prepare.h" +#include "lang/lang_keys.h" #include #include @@ -1865,6 +1868,1102 @@ void MeasurePreparedFormulas(PrepareState *state) { = state->result.debug.formulaMeasureMs; } +struct NativeIvPhotoInfo { + uint64 id = 0; + int width = 0; + int height = 0; +}; + +struct NativeIvPrepareState { + MarkdownArticleContent result; + std::vector photos; + int nextGeneratedId = 0; + + void setFailure( + PrepareTerminalFailure terminal, + QString debugReason) { + if (result.failure.failed()) { + return; + } + result.failure.terminal = terminal; + result.failure.debugReason = std::move(debugReason); + } + + [[nodiscard]] bool blocked() const { + return result.failure.failed(); + } +}; + +struct PreparedIvRichText { + TextWithEntities text; + std::vector links; +}; + +[[nodiscard]] bool PrepareNativeIvBlocks( + const QVector &blocks, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvBlock( + const MTPPageBlock &block, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool AppendNativeIvRichText( + const MTPRichText &text, + TextWithEntities *result, + std::vector *links, + QString *blockAnchorId, + NativeIvPrepareState *state); + +void ShiftEntities(EntitiesInText *entities, int delta) { + if (!delta) { + return; + } + for (auto &entity : *entities) { + entity = EntityInText( + entity.type(), + entity.offset() + delta, + entity.length(), + entity.data()); + } +} + +void PrependText(TextWithEntities *text, QString prefix) { + if (prefix.isEmpty()) { + return; + } + text->text.prepend(prefix); + ShiftEntities(&text->entities, prefix.size()); +} + +[[nodiscard]] QString NativeIvDateText(TimeId date) { + return langDateTimeFull(base::unixtime::parse(date)); +} + +[[nodiscard]] QString NativeIvDetailsAnchorId(NativeIvPrepareState *state) { + return u"details-"_q + QString::number(++state->nextGeneratedId); +} + +void RememberNativeIvPhoto( + NativeIvPrepareState *state, + const MTPPhoto &photo) { + auto info = NativeIvPhotoInfo{ + .id = photo.match([](const auto &data) { + return data.vid().v; + }), + }; + photo.match([](const MTPDphotoEmpty &) { + }, [&](const MTPDphoto &data) { + auto width = 0; + auto height = 0; + const auto assign = [&](int w, int h) { + if (w > 0 && h > 0) { + width = w; + height = h; + } + }; + for (const auto &size : data.vsizes().v) { + size.match([&](const MTPDphotoSizeEmpty &) { + }, [&](const MTPDphotoSize &data) { + if (data.vtype().v == u"y"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"x"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"w"_q) { + assign(data.vw().v, data.vh().v); + } + }, [&](const MTPDphotoCachedSize &data) { + if (data.vtype().v == u"y"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"x"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"w"_q) { + assign(data.vw().v, data.vh().v); + } + }, [&](const MTPDphotoStrippedSize &) { + }, [&](const MTPDphotoSizeProgressive &data) { + if (data.vtype().v == u"y"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"x"_q) { + assign(data.vw().v, data.vh().v); + } else if (!width && data.vtype().v == u"w"_q) { + assign(data.vw().v, data.vh().v); + } + }, [&](const MTPDphotoPathSize &) { + }); + } + info.width = width; + info.height = height; + }); + if (!info.id) { + return; + } + for (auto &existing : state->photos) { + if (existing.id == info.id) { + existing = info; + return; + } + } + state->photos.push_back(info); +} + +[[nodiscard]] const NativeIvPhotoInfo *FindNativeIvPhoto( + uint64 id, + const NativeIvPrepareState &state) { + for (const auto &photo : state.photos) { + if (photo.id == id) { + return &photo; + } + } + return nullptr; +} + +[[nodiscard]] bool AddNativeIvPreparedLink( + TextWithEntities *text, + std::vector *links, + int from, + int length, + QString target) { + if (!length || target.isEmpty()) { + return true; + } + const auto index = links->size() + 1; + if (index > std::numeric_limits::max()) { + return true; + } + const auto prepared = ClassifiedLink(uint16(index), target, nullptr); + if (prepared.kind == PreparedLinkKind::RejectedRelative + || prepared.kind == PreparedLinkKind::LocalFile) { + return true; + } + text->entities.push_back(EntityInText( + EntityType::CustomUrl, + from, + length, + InternalLinkData(uint16(index)))); + links->push_back({ + .index = uint16(index), + .kind = prepared.kind, + .target = prepared.target, + .fragment = prepared.fragment, + .copyText = prepared.copyText, + }); + return true; +} + +[[nodiscard]] bool AddNativeIvEntity( + TextWithEntities *text, + int from, + EntityType type) { + const auto length = text->text.size() - from; + if (length <= 0) { + return true; + } + text->entities.push_back(EntityInText(type, from, length)); + return true; +} + +[[nodiscard]] bool AppendNativeIvRichText( + const MTPRichText &text, + TextWithEntities *result, + std::vector *links, + QString *blockAnchorId, + NativeIvPrepareState *state) { + if (state->blocked()) { + return false; + } + return text.match([&](const MTPDtextEmpty &) { + return true; + }, [&](const MTPDtextPlain &data) { + result->append(qs(data.vtext())); + return true; + }, [&](const MTPDtextConcat &data) { + for (const auto &part : data.vtexts().v) { + if (!AppendNativeIvRichText( + part, + result, + links, + blockAnchorId, + state)) { + return false; + } + } + return true; + }, [&](const MTPDtextImage &data) { + const auto replacementText = u"[image]"_q; + if (!data.vdocument_id().v || data.vw().v <= 0 || data.vh().v <= 0) { + result->append(replacementText); + return true; + } + const auto entityData = SerializeInlineTextObjectEntity({ + .kind = InlineTextObjectKind::IvImage, + .data = InlineTextObjectIvImageData{ + .documentId = uint64(data.vdocument_id().v), + .width = data.vw().v, + .height = data.vh().v, + .replacementText = replacementText, + }, + }); + if (entityData.isEmpty()) { + result->append(replacementText); + return true; + } + const auto from = result->text.size(); + result->append(QChar::ObjectReplacementCharacter); + result->entities.push_back(EntityInText( + EntityType::CustomEmoji, + from, + 1, + entityData)); + return true; + }, [&](const MTPDtextBold &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Bold); + }, [&](const MTPDtextItalic &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Italic); + }, [&](const MTPDtextUnderline &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Underline); + }, [&](const MTPDtextStrike &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::StrikeOut); + }, [&](const MTPDtextFixed &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Code); + }, [&](const MTPDtextUrl &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + if (result->text.size() == from) { + result->append(qs(data.vurl())); + } + return AddNativeIvPreparedLink( + result, + links, + from, + result->text.size() - from, + qs(data.vurl())); + }, [&](const MTPDtextEmail &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + if (result->text.size() == from) { + result->append(qs(data.vemail())); + } + return AddNativeIvPreparedLink( + result, + links, + from, + result->text.size() - from, + u"mailto:"_q + qs(data.vemail())); + }, [&](const MTPDtextSubscript &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Subscript); + }, [&](const MTPDtextSuperscript &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Superscript); + }, [&](const MTPDtextMarked &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + return AddNativeIvEntity(result, from, EntityType::Marked); + }, [&](const MTPDtextPhone &data) { + const auto from = result->text.size(); + if (!AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state)) { + return false; + } + if (result->text.size() == from) { + result->append(qs(data.vphone())); + } + return AddNativeIvPreparedLink( + result, + links, + from, + result->text.size() - from, + u"tel:"_q + qs(data.vphone())); + }, [&](const MTPDtextAnchor &data) { + if (blockAnchorId && blockAnchorId->isEmpty()) { + *blockAnchorId = NormalizeFragmentId(qs(data.vname())); + } + return AppendNativeIvRichText( + data.vtext(), + result, + links, + blockAnchorId, + state); + }); +} + +[[nodiscard]] bool PrepareNativeIvRichText( + const MTPRichText &text, + PreparedIvRichText *result, + QString *blockAnchorId, + NativeIvPrepareState *state) { + return AppendNativeIvRichText( + text, + &result->text, + &result->links, + blockAnchorId, + state); +} + +[[nodiscard]] bool PrepareNativeIvCaption( + const MTPPageCaption &caption, + PreparedIvRichText *result, + QString *blockAnchorId, + NativeIvPrepareState *state) { + if (!AppendNativeIvRichText( + caption.data().vtext(), + &result->text, + &result->links, + blockAnchorId, + state)) { + return false; + } + auto credit = PreparedIvRichText(); + if (!PrepareNativeIvRichText( + caption.data().vcredit(), + &credit, + blockAnchorId, + state)) { + return false; + } + if (!credit.text.text.isEmpty()) { + if (!result->text.text.isEmpty()) { + result->text.append(QChar('\n')); + } + if (!AppendNativeIvRichText( + caption.data().vcredit(), + &result->text, + &result->links, + blockAnchorId, + state)) { + return false; + } + } + return true; +} + +void SortPreparedIvRichText(PreparedIvRichText *text) { + SortEntities(&text->text); +} + +[[nodiscard]] bool AppendPreparedIvRichBlock( + std::vector *result, + PreparedBlockKind kind, + int headingLevel, + PreparedIvRichText prepared, + QString anchorId = QString(), + bool allowEmpty = false) { + SortPreparedIvRichText(&prepared); + AppendRichBlock( + result, + kind, + headingLevel, + std::move(prepared.text), + std::move(prepared.links), + std::move(anchorId), + false, + allowEmpty); + return true; +} + +[[nodiscard]] bool AppendNativeIvFlowBlock( + std::vector *result, + PreparedBlockKind kind, + int headingLevel, + const MTPRichText &text, + NativeIvPrepareState *state, + bool allowEmpty = false) { + auto prepared = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvRichText(text, &prepared, &anchorId, state)) { + return false; + } + return AppendPreparedIvRichBlock( + result, + kind, + headingLevel, + std::move(prepared), + std::move(anchorId), + allowEmpty); +} + +bool PrepareNativeIvPlainPlaceholderBlock( + QString label, + std::vector *result) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Placeholder; + block.placeholder.label = std::move(label); + block.placeholder.copyText = block.placeholder.label; + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareNativeIvPlaceholderBlock( + QString label, + const MTPPageCaption &caption, + std::vector *result, + NativeIvPrepareState *state); + +[[nodiscard]] bool PrepareNativeIvPhotoBlock( + const MTPDpageBlockPhoto &data, + std::vector *result, + NativeIvPrepareState *state) { + const auto info = FindNativeIvPhoto(uint64(data.vphoto_id().v), *state); + if (!info || info->width <= 0 || info->height <= 0) { + return PrepareNativeIvPlaceholderBlock( + u"Photo Placeholder"_q, + data.vcaption(), + result, + state); + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.vcaption(), &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Photo; + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = std::move(anchorId); + block.photo.photoId = info->id; + block.photo.width = info->width; + block.photo.height = info->height; + block.photo.urlOverride = data.vurl() ? qs(*data.vurl()) : QString(); + block.photo.viewerOpen = true; + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] QString NativeIvPlaceholderCopyText( + const QString &label, + const TextWithEntities &caption) { + return caption.text.isEmpty() + ? label + : (label + u"\n"_q + caption.text); +} + +[[nodiscard]] bool PrepareNativeIvPlaceholderBlock( + QString label, + const MTPPageCaption &caption, + std::vector *result, + NativeIvPrepareState *state) { + auto prepared = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(caption, &prepared, &anchorId, state)) { + return state->result.failure.failed() + ? false + : PrepareNativeIvPlainPlaceholderBlock(std::move(label), result); + } + SortPreparedIvRichText(&prepared); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Placeholder; + block.text = std::move(prepared.text); + block.links = std::move(prepared.links); + block.anchorId = std::move(anchorId); + block.placeholder.label = label; + block.placeholder.copyText = NativeIvPlaceholderCopyText( + block.placeholder.label, + block.text); + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareNativeIvQuoteBlock( + const MTPRichText &text, + const MTPRichText &caption, + std::vector *result, + NativeIvPrepareState *state) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Quote; + auto body = PreparedIvRichText(); + if (!PrepareNativeIvRichText(text, &body, &block.anchorId, state)) { + return false; + } + if (!AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(body))) { + return false; + } + auto cite = PreparedIvRichText(); + if (!PrepareNativeIvRichText(caption, &cite, &block.anchorId, state)) { + return false; + } + if (!AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(cite))) { + return false; + } + if (block.children.empty()) { + block.children.push_back(EmptyParagraphBlock()); + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareNativeIvList( + const QVector &items, + PreparedBlock *result, + NativeIvPrepareState *state) { + for (const auto &item : items) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::ListItem; + block.listKind = result->listKind; + block.listDelimiter = result->listDelimiter; + const auto ok = item.match([&](const MTPDpageListItemText &data) { + auto prepared = PreparedIvRichText(); + if (!PrepareNativeIvRichText( + data.vtext(), + &prepared, + &block.anchorId, + state)) { + return false; + } + return AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared)); + }, [&](const MTPDpageListItemBlocks &data) { + return PrepareNativeIvBlocks(data.vblocks().v, &block.children, state); + }); + if (!ok) { + return false; + } + if (block.children.empty()) { + block.children.push_back(EmptyParagraphBlock()); + } + result->children.push_back(std::move(block)); + } + return true; +} + +[[nodiscard]] bool ParseOrderedNumber( + const QString &value, + int *result) { + auto ok = false; + const auto parsed = value.toInt(&ok); + if (!ok) { + return false; + } + *result = parsed; + return true; +} + +[[nodiscard]] int NextNativeIvOrderedNumber(const PreparedBlock &result) { + return result.children.empty() + ? result.startNumber + : (result.children.back().orderedNumber + 1); +} + +[[nodiscard]] bool PrepareNativeIvOrderedList( + const QVector &items, + PreparedBlock *result, + NativeIvPrepareState *state) { + auto firstNumber = true; + for (const auto &item : items) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::ListItem; + block.listKind = result->listKind; + block.listDelimiter = result->listDelimiter; + const auto ok = item.match([&](const MTPDpageListOrderedItemText &data) { + auto orderedNumber = 0; + if (!ParseOrderedNumber(qs(data.vnum()), &orderedNumber)) { + orderedNumber = NextNativeIvOrderedNumber(*result); + } + block.orderedNumber = orderedNumber; + auto prepared = PreparedIvRichText(); + if (!PrepareNativeIvRichText( + data.vtext(), + &prepared, + &block.anchorId, + state)) { + return false; + } + return AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared)); + }, [&](const MTPDpageListOrderedItemBlocks &data) { + auto orderedNumber = 0; + if (!ParseOrderedNumber(qs(data.vnum()), &orderedNumber)) { + orderedNumber = NextNativeIvOrderedNumber(*result); + } + block.orderedNumber = orderedNumber; + return PrepareNativeIvBlocks(data.vblocks().v, &block.children, state); + }); + if (!ok) { + return false; + } + if (firstNumber) { + result->startNumber = block.orderedNumber; + firstNumber = false; + } + if (block.children.empty()) { + block.children.push_back(EmptyParagraphBlock()); + } + result->children.push_back(std::move(block)); + } + return true; +} + +[[nodiscard]] TableAlignment NativeIvTableAlignment( + const MTPDpageTableCell &cell) { + if (cell.is_align_right()) { + return TableAlignment::Right; + } else if (cell.is_align_center()) { + return TableAlignment::Center; + } + return TableAlignment::Left; +} + +[[nodiscard]] bool PrepareNativeIvTableBlock( + const MTPDpageBlockTable &data, + std::vector *result, + NativeIvPrepareState *state) { + auto title = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvRichText(data.vtitle(), &title, &anchorId, state)) { + return false; + } + if (!AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Paragraph, + 0, + std::move(title), + std::move(anchorId))) { + return false; + } + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Table; + for (const auto &row : data.vrows().v) { + auto preparedRow = PreparedTableRow(); + auto header = std::optional(); + auto column = 0; + const auto ok = row.match([&](const MTPDpageTableRow &data) { + for (const auto &cell : data.vcells().v) { + auto preparedCell = PreparedTableCell(); + const auto cellOk = cell.match([&](const MTPDpageTableCell &data) { + if ((data.vcolspan() && data.vcolspan()->v > 1) + || (data.vrowspan() && data.vrowspan()->v > 1)) { + return false; + } + const auto cellHeader = data.is_header(); + if (header && *header != cellHeader) { + return false; + } + header = cellHeader; + preparedCell.column = column++; + preparedCell.alignment = NativeIvTableAlignment(data); + if (data.vtext()) { + auto rich = PreparedIvRichText(); + if (!PrepareNativeIvRichText( + *data.vtext(), + &rich, + nullptr, + state)) { + return false; + } + SortPreparedIvRichText(&rich); + preparedCell.text = std::move(rich.text); + preparedCell.links = std::move(rich.links); + } + return true; + }); + if (!cellOk) { + return false; + } + preparedRow.cells.push_back(std::move(preparedCell)); + } + return true; + }); + if (!ok) { + return state->result.failure.failed() + ? false + : PrepareNativeIvPlainPlaceholderBlock( + u"Table Placeholder"_q, + result); + } + preparedRow.header = header.value_or(false); + if (!block.tableColumnCount) { + block.tableColumnCount = column; + block.tableAlignments.resize(column, TableAlignment::Left); + } else if (block.tableColumnCount != column) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Table Placeholder"_q, + result); + } + for (const auto &cell : preparedRow.cells) { + if (cell.column >= 0 && cell.column < int(block.tableAlignments.size())) { + block.tableAlignments[cell.column] = cell.alignment; + } + } + block.tableRows.push_back(std::move(preparedRow)); + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareNativeIvDetailsBlock( + const MTPDpageBlockDetails &data, + std::vector *result, + NativeIvPrepareState *state) { + auto summary = PreparedIvRichText(); + auto anchorId = NativeIvDetailsAnchorId(state); + if (!PrepareNativeIvRichText( + data.vtitle(), + &summary, + &anchorId, + state)) { + return false; + } + if (!summary.links.empty()) { + const auto isLink = [](const EntityInText &entity) { + return entity.type() == EntityType::CustomUrl; + }; + const auto from = std::remove_if( + summary.text.entities.begin(), + summary.text.entities.end(), + isLink); + summary.text.entities.erase(from, summary.text.entities.end()); + summary.links.clear(); + } + PrependText(&summary.text, data.is_open() ? u"v "_q : u"> "_q); + SortPreparedIvRichText(&summary); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Details; + block.anchorId = std::move(anchorId); + block.collapsed = !data.is_open(); + block.text = std::move(summary.text); + block.links = std::move(summary.links); + const auto toggleIndex = block.links.size() + 1; + if (toggleIndex <= std::numeric_limits::max()) { + block.text.entities.push_back(EntityInText( + EntityType::CustomUrl, + 0, + block.text.text.size(), + InternalLinkData(uint16(toggleIndex)))); + block.links.push_back({ + .index = uint16(toggleIndex), + .kind = PreparedLinkKind::ToggleDetails, + .target = block.anchorId, + }); + } + if (!PrepareNativeIvBlocks(data.vblocks().v, &block.children, state)) { + return false; + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareNativeIvBlocks( + const QVector &blocks, + std::vector *result, + NativeIvPrepareState *state) { + for (const auto &block : blocks) { + if (!PrepareNativeIvBlock(block, result, state)) { + if (state->result.failure.failed()) { + return false; + } + PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Block Placeholder"_q, + result); + } + } + return !state->result.failure.failed(); +} + +[[nodiscard]] bool PrepareNativeIvBlock( + const MTPPageBlock &block, + std::vector *result, + NativeIvPrepareState *state) { + if (state->blocked()) { + return false; + } + return block.match([&](const MTPDpageBlockUnsupported &) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Block Placeholder"_q, + result); + }, [&](const MTPDpageBlockTitle &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + 1, + data.vtext(), + state); + }, [&](const MTPDpageBlockSubtitle &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + 2, + data.vtext(), + state); + }, [&](const MTPDpageBlockAuthorDate &data) { + auto prepared = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvRichText( + data.vauthor(), + &prepared, + &anchorId, + state)) { + return false; + } + if (const auto date = data.vpublished_date().v) { + if (!prepared.text.text.isEmpty()) { + prepared.text.append(u" • "_q); + } + prepared.text.append(NativeIvDateText(date)); + } + return AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared), + std::move(anchorId)); + }, [&](const MTPDpageBlockHeader &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + 3, + data.vtext(), + state); + }, [&](const MTPDpageBlockSubheader &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + 4, + data.vtext(), + state); + }, [&](const MTPDpageBlockParagraph &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Paragraph, + 0, + data.vtext(), + state); + }, [&](const MTPDpageBlockPreformatted &data) { + auto prepared = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvRichText( + data.vtext(), + &prepared, + &anchorId, + state)) { + return false; + } + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::CodeBlock; + block.anchorId = std::move(anchorId); + block.codeLanguage = qs(data.vlanguage()).trimmed(); + block.text.text = prepared.text.text; + result->push_back(std::move(block)); + return true; + }, [&](const MTPDpageBlockFooter &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Paragraph, + 0, + data.vtext(), + state); + }, [&](const MTPDpageBlockDivider &) { + result->push_back(PrepareRuleBlock()); + return true; + }, [&](const MTPDpageBlockAnchor &data) { + const auto anchorId = NormalizeFragmentId(qs(data.vname())); + if (anchorId.isEmpty()) { + return true; + } + auto prepared = PreparedIvRichText(); + return AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared), + anchorId, + true); + }, [&](const MTPDpageBlockList &data) { + auto prepared = PreparedBlock(); + prepared.kind = PreparedBlockKind::List; + prepared.listKind = ListKind::Bullet; + return PrepareNativeIvList(data.vitems().v, &prepared, state) + ? (result->push_back(std::move(prepared)), true) + : false; + }, [&](const MTPDpageBlockBlockquote &data) { + return PrepareNativeIvQuoteBlock( + data.vtext(), + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockPullquote &data) { + return PrepareNativeIvQuoteBlock( + data.vtext(), + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockPhoto &data) { + return PrepareNativeIvPhotoBlock(data, result, state); + }, [&](const MTPDpageBlockVideo &data) { + return PrepareNativeIvPlaceholderBlock( + u"Video Placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockCover &data) { + return PrepareNativeIvBlock(data.vcover(), result, state); + }, [&](const MTPDpageBlockEmbed &data) { + return PrepareNativeIvPlaceholderBlock( + u"Embed Placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockEmbedPost &data) { + return PrepareNativeIvPlaceholderBlock( + u"Embed Placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockCollage &data) { + return PrepareNativeIvPlaceholderBlock( + u"Collage placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockSlideshow &data) { + return PrepareNativeIvPlaceholderBlock( + u"Grouped Media Placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockChannel &) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Channel Placeholder"_q, + result); + }, [&](const MTPDpageBlockAudio &data) { + return PrepareNativeIvPlaceholderBlock( + u"Audio File Placeholder"_q, + data.vcaption(), + result, + state); + }, [&](const MTPDpageBlockKicker &data) { + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + 5, + data.vtext(), + state); + }, [&](const MTPDpageBlockTable &data) { + return PrepareNativeIvTableBlock(data, result, state); + }, [&](const MTPDpageBlockOrderedList &data) { + auto prepared = PreparedBlock(); + prepared.kind = PreparedBlockKind::List; + prepared.listKind = ListKind::Ordered; + prepared.listDelimiter = ListDelimiter::Period; + prepared.startNumber = 1; + return PrepareNativeIvOrderedList(data.vitems().v, &prepared, state) + ? (result->push_back(std::move(prepared)), true) + : false; + }, [&](const MTPDpageBlockDetails &data) { + return PrepareNativeIvDetailsBlock(data, result, state); + }, [&](const MTPDpageBlockRelatedArticles &) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Related Articles Placeholder"_q, + result); + }, [&](const MTPDpageBlockMap &data) { + return PrepareNativeIvPlaceholderBlock( + u"Map Placeholder"_q, + data.vcaption(), + result, + state); + }); +} + } // namespace QString SerializeInlineTextObjectEntity(const InlineTextObjectEntity &object) { @@ -2004,4 +3103,65 @@ MarkdownArticleContent PrepareSynchronously(PrepareRequest request) { return finish(); } +NativeInstantViewPrepareResult TryPrepareNativeInstantView( + NativeInstantViewPrepareRequest request) { + auto state = NativeIvPrepareState(); + auto timer = QElapsedTimer(); + timer.start(); + state.result.mediaRuntime = std::move(request.mediaRuntime); + const auto finish = [&](NativeInstantViewPrepareResultKind kind, QString reason) { + state.result.debug.prepareMs = int(timer.elapsed()); + return NativeInstantViewPrepareResult{ + .kind = kind, + .content = std::move(state.result), + .debugReason = std::move(reason), + }; + }; + + if (!request.source) { + state.setFailure( + PrepareTerminalFailure::InvalidRequest, + u"missing-native-iv-source"_q); + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + + for (const auto &photo : request.source->page.data().vphotos().v) { + RememberNativeIvPhoto(&state, photo); + } + if (request.source->webpagePhoto) { + RememberNativeIvPhoto(&state, *request.source->webpagePhoto); + } + + if (!PrepareNativeIvBlocks( + request.source->page.data().vblocks().v, + &state.result.blocks.blocks, + &state)) { + if (state.result.failure.failed()) { + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Block Placeholder"_q, + &state.result.blocks.blocks); + } + if (CountPreparedBlocks(state.result.blocks.blocks) + > PrepareLimitsForIv().maxPreparedBlocks) { + state.setFailure( + PrepareTerminalFailure::DocumentTooLarge, + u"prepared-block-limit"_q); + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + return finish( + NativeInstantViewPrepareResultKind::Supported, + QString()); +} + } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index c38039b24f..093189764e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -11,6 +11,10 @@ #include #include +namespace Iv { +struct Source; +} // namespace Iv + namespace Iv::Markdown { enum class PreparedBlockKind { @@ -24,6 +28,8 @@ enum class PreparedBlockKind { DisplayMath, Table, Details, + Photo, + Placeholder, }; enum class PreparedLinkKind { @@ -80,6 +86,19 @@ struct PreparedTableRow { bool header = false; }; +struct PreparedPhotoBlockData { + uint64 photoId = 0; + int width = 0; + int height = 0; + QString urlOverride; + bool viewerOpen = false; +}; + +struct PreparedPlaceholderBlockData { + QString label; + QString copyText; +}; + struct PreparedBlock { PreparedBlockKind kind = PreparedBlockKind::Paragraph; TextWithEntities text; @@ -90,6 +109,8 @@ struct PreparedBlock { QString codeLanguage; QString formulaTex; QString anchorId; + PreparedPhotoBlockData photo; + PreparedPlaceholderBlockData placeholder; ListKind listKind = ListKind::Bullet; ListDelimiter listDelimiter = ListDelimiter::None; MathKind mathKind = MathKind::Display; @@ -174,13 +195,44 @@ struct PrepareRequest { QString sourcePath; }; +struct NativeInstantViewPrepareRequest { + const Iv::Source *source = nullptr; + std::shared_ptr mediaRuntime; +}; + struct MarkdownArticleContent { PreparedRenderDocument blocks; std::vector formulas; + std::shared_ptr mediaRuntime; PrepareFailureStatus failure; PrepareDebugStats debug; }; +enum class NativeInstantViewPrepareResultKind { + Supported, + Unsupported, + Failure, +}; + +struct NativeInstantViewPrepareResult { + NativeInstantViewPrepareResultKind kind + = NativeInstantViewPrepareResultKind::Unsupported; + MarkdownArticleContent content; + QString debugReason; + + [[nodiscard]] bool supported() const { + return (kind == NativeInstantViewPrepareResultKind::Supported); + } + + [[nodiscard]] bool unsupported() const { + return (kind == NativeInstantViewPrepareResultKind::Unsupported); + } + + [[nodiscard]] bool failed() const { + return (kind == NativeInstantViewPrepareResultKind::Failure); + } +}; + [[nodiscard]] const MarkdownPrepareTableRenderLimits &PrepareTableRenderLimitsForIv(); [[nodiscard]] const MarkdownPrepareLimits &PrepareLimitsForIv(); [[nodiscard]] MarkdownPrepareDimensions CaptureMarkdownPrepareDimensions(); @@ -189,5 +241,7 @@ struct MarkdownArticleContent { [[nodiscard]] std::optional ParseInlineTextObjectEntity( const QString &data); [[nodiscard]] MarkdownArticleContent PrepareSynchronously(PrepareRequest request); +[[nodiscard]] NativeInstantViewPrepareResult TryPrepareNativeInstantView( + NativeInstantViewPrepareRequest request); } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index f42d82c4c9..d7cbb6b70d 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -143,6 +143,8 @@ public: void setLinkActivationCallback( std::function callback); + void setMediaActivationCallback( + std::function callback); void setArticle(std::shared_ptr article); void setZoom(int value); void refreshPalette(); @@ -213,6 +215,7 @@ private: std::unique_ptr _prePaintCache; std::unique_ptr _blockquotePaintCache; std::function _activateLink; + std::function _activateMedia; MarkdownArticleSelection _selection; MarkdownArticleSelection _savedSelection; MarkdownArticleSelectionEndpoints _selectionEndpoints; @@ -243,6 +246,11 @@ void MarkdownDocumentWidget::setLinkActivationCallback( _activateLink = std::move(callback); } +void MarkdownDocumentWidget::setMediaActivationCallback( + std::function callback) { + _activateMedia = std::move(callback); +} + void MarkdownDocumentWidget::setArticle(std::shared_ptr article) { ClickHandler::clearActive(this); applyCursor(style::cur_default); @@ -721,7 +729,8 @@ void MarkdownDocumentWidget::updateHover( const auto changed = ClickHandler::setActive(state.state.link, this); auto cursor = style::cur_default; if (_dragAction == NoDrag) { - if (state.state.link) { + if (state.state.link + || state.mediaActivation.kind != MediaActivationKind::None) { cursor = style::cur_pointer; } else if (state.direct) { cursor = style::cur_text; @@ -804,6 +813,11 @@ MarkdownArticlePaintCaches MarkdownDocumentWidget::textPaintCaches() { return { .pre = ensurePrePaintCache(), .blockquote = ensureBlockquotePaintCache(), + .repaint = [=] { + crl::on_main(this, [=] { + update(); + }); + }, }; } @@ -879,11 +893,21 @@ MarkdownArticleHitTestResult MarkdownDocumentWidget::dragActionFinish( updateHover(state); if (activated && (button == Qt::LeftButton || button == Qt::MiddleButton)) { + if (state.mediaActivation.kind != MediaActivationKind::None + && _activateMedia + && _activateMedia(state.mediaActivation, button)) { + return state; + } if (state.preparedLink && _activateLink) { _activateLink(*state.preparedLink, button); } else { ActivateClickHandler(window(), activated, button); } + } else if ((button == Qt::LeftButton || button == Qt::MiddleButton) + && state.mediaActivation.kind != MediaActivationKind::None + && _activateMedia + && _activateMedia(state.mediaActivation, button)) { + return state; } if (QGuiApplication::clipboard()->supportsSelection() && !_selection.empty()) { @@ -912,8 +936,15 @@ public: const PreparedDocument &document, Fn callback, const OpenOptions &options); + MarkdownPreviewRoot( + QWidget *parent, + MarkdownArticleContent content, + std::shared_ptr renderer, + Fn callback, + const OpenOptions &options); private: + void setup(); void prepareArticle(); void activateLink(const PreparedLink &link, Qt::MouseButton button); void applyPreparedContent(MarkdownArticleContent prepared, int prepareMs); @@ -929,12 +960,13 @@ private: const OpenOptions _options; const std::shared_ptr _document; + std::optional _preparedContent; const Fn _callback; Ui::ScrollArea *_scroll = nullptr; MarkdownDocumentWidget *_body = nullptr; Ui::FlatLabel *_failure = nullptr; Ui::LinkButton *_failureOpen = nullptr; - std::shared_ptr _renderer; + const std::shared_ptr _renderer; QString _pendingFragment; int _devicePixelRatio = 0; @@ -951,6 +983,25 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( , _callback(std::move(callback)) , _renderer(std::make_shared()) , _pendingFragment(options.initialFragment) { + setup(); +} + +MarkdownPreviewRoot::MarkdownPreviewRoot( + QWidget *parent, + MarkdownArticleContent content, + std::shared_ptr renderer, + Fn callback, + const OpenOptions &options) +: Ui::RpWidget(parent) +, _options(options) +, _preparedContent(std::move(content)) +, _callback(std::move(callback)) +, _renderer(renderer ? std::move(renderer) : std::make_shared()) +, _pendingFragment(options.initialFragment) { + setup(); +} + +void MarkdownPreviewRoot::setup() { _scroll = Ui::CreateChild(this, st::boxScroll); _body = _scroll->setOwnedWidget(object_ptr(_scroll)); _failure = Ui::CreateChild( @@ -969,6 +1020,13 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( Qt::MouseButton button) { activateLink(link, button); }); + _body->setMediaActivationCallback([=]( + const MediaActivation &activation, + Qt::MouseButton button) { + return _options.activateMedia + ? _options.activateMedia(activation, button) + : false; + }); if (_options.delegate) { _body->setZoom(_options.delegate->ivZoom()); } @@ -1023,6 +1081,11 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( } void MarkdownPreviewRoot::prepareArticle() { + if (_preparedContent) { + applyPreparedContent(std::move(*_preparedContent), 0); + _preparedContent.reset(); + return; + } if (_renderer) { _renderer->resetDebugCounters(); } @@ -1056,6 +1119,14 @@ void MarkdownPreviewRoot::activateLink( } break; case PreparedLinkKind::LocalFile: { + auto target = link.target; + if (!link.fragment.isEmpty()) { + target += u"#"_q + link.fragment; + } + _callback({ + .type = Event::Type::OpenFile, + .url = std::move(target), + }); } break; case PreparedLinkKind::RejectedRelative: DEBUG_LOG(("Native Markdown IV: " @@ -1218,4 +1289,18 @@ std::unique_ptr CreateMarkdownPreviewWidget( options); } +std::unique_ptr CreateMarkdownPreviewWidget( + QWidget *parent, + MarkdownArticleContent content, + std::shared_ptr renderer, + Fn callback, + const OpenOptions &options) { + return std::make_unique( + parent, + std::move(content), + std::move(renderer), + std::move(callback), + options); +} + } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h index 7769016be6..14f9e29b35 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h @@ -1,6 +1,7 @@ #pragma once #include "iv/markdown/iv_markdown_document.h" +#include "iv/markdown/iv_markdown_prepare.h" #include @@ -15,5 +16,11 @@ namespace Iv::Markdown { const PreparedDocument &document, Fn callback, const OpenOptions &options = {}); +[[nodiscard]] std::unique_ptr CreateMarkdownPreviewWidget( + QWidget *parent, + MarkdownArticleContent content, + std::shared_ptr renderer, + Fn callback, + const OpenOptions &options = {}); } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index 0fec9382a2..c647379e6b 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -4,7 +4,10 @@ #include "iv/markdown/iv_markdown_microtex.h" #include "iv/markdown/iv_markdown_parse.h" #include "iv/markdown/iv_markdown_prepare.h" +#include "iv/iv_prepare.h" +#include "scheme.h" +#include "ui/dynamic_image.h" #include "ui/style/style_core.h" #include "ui/style/style_core_scale.h" @@ -17,16 +20,20 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include #include #include +#include namespace { @@ -48,6 +55,313 @@ struct PreparedFixture { MarkdownArticleContent prepared; }; +class TestDynamicImage final : public Ui::DynamicImage { +public: + explicit TestDynamicImage(QImage frame = QImage()) + : _frame(std::move(frame)) { + } + + [[nodiscard]] std::shared_ptr clone() override { + return std::make_shared(_frame); + } + + [[nodiscard]] QImage image(int size) override { + requestedSizes.push_back(size); + return _frame; + } + + void subscribeToUpdates(Fn callback) override { + ++subscriptionCount; + _callback = std::move(callback); + } + + void setFrame(QImage frame) { + _frame = std::move(frame); + } + + void notify() const { + if (_callback) { + _callback(); + } + } + + mutable std::vector requestedSizes; + mutable int subscriptionCount = 0; + +private: + QImage _frame; + mutable Fn _callback; +}; + +class TestPhotoRuntime final : public PhotoRuntime { +public: + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + thumbnailSizes.push_back(size); + return thumbnailImage; + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + fullSizes.push_back(size); + return fullImage; + } + + [[nodiscard]] bool loaded() const override { + return loadedValue; + } + + [[nodiscard]] bool loading() const override { + return loadingValue; + } + + [[nodiscard]] double progress() const override { + return progressValue; + } + + void open(Qt::MouseButton button) const override { + openedButtons.push_back(button); + } + + std::shared_ptr thumbnailImage; + std::shared_ptr fullImage; + bool loadedValue = false; + bool loadingValue = false; + double progressValue = 0.; + mutable std::vector thumbnailSizes; + mutable std::vector fullSizes; + mutable std::vector openedButtons; +}; + +class TestMediaRuntime final : public MediaRuntime { +public: + [[nodiscard]] std::shared_ptr resolveInlineImage( + uint64 documentId, + QSize size) const override { + inlineRequests.push_back({ documentId, size }); + return (documentId == inlineDocumentId) ? inlineImage : nullptr; + } + + [[nodiscard]] std::shared_ptr resolvePhoto( + uint64 photoId) const override { + photoRequests.push_back(photoId); + return (photoId == preparedPhotoId) ? photoRuntime : nullptr; + } + + uint64 inlineDocumentId = 0; + uint64 preparedPhotoId = 0; + std::shared_ptr inlineImage; + std::shared_ptr photoRuntime; + mutable std::vector> inlineRequests; + mutable std::vector photoRequests; +}; + +enum class NativeIvPlaceholderKind { + Video, + Embed, + EmbedPost, + Collage, + Slideshow, + Audio, + Map, +}; + +struct NativeIvPlaceholderFixture { + NativeIvPlaceholderKind kind = NativeIvPlaceholderKind::Video; + QString caption; + QString expectedLabel; + MTPPageBlock block; +}; + +[[nodiscard]] QImage SolidTestImage( + int width, + int height, + const QColor &color) { + auto result = QImage( + QSize(std::max(width, 1), std::max(height, 1)), + QImage::Format_ARGB32_Premultiplied); + result.fill(color); + return result; +} + +[[nodiscard]] MTPRichText NativeIvText(QString text) { + return text.isEmpty() + ? MTP_textEmpty() + : MTP_textPlain(MTP_string(text)); +} + +[[nodiscard]] MTPRichText NativeIvConcat( + std::initializer_list parts) { + auto list = QVector(); + list.reserve(int(parts.size())); + for (const auto &part : parts) { + list.push_back(part); + } + return MTP_textConcat(MTP_vector(std::move(list))); +} + +[[nodiscard]] MTPPageCaption NativeIvCaption( + QString text = QString(), + QString credit = QString()) { + return MTP_pageCaption( + NativeIvText(std::move(text)), + NativeIvText(std::move(credit))); +} + +[[nodiscard]] MTPPhoto NativeIvPhoto(uint64 id, int width, int height) { + auto sizes = QVector(); + sizes.push_back(MTP_photoSize( + MTP_string("y"), + MTP_int(width), + MTP_int(height), + MTP_int(0))); + return MTP_photo( + MTP_flags(0), + MTP_long(id), + MTP_long(0), + MTP_bytes(), + MTP_int(0), + MTP_vector(std::move(sizes)), + MTPVector(), + MTP_int(0)); +} + +[[nodiscard]] MTPPageBlock NativeIvPhotoBlock( + uint64 photoId, + QString caption = QString(), + QString credit = QString(), + QString urlOverride = QString()) { + const auto flags = urlOverride.isEmpty() + ? MTP_flags(0) + : MTP_flags(MTPDpageBlockPhoto::Flag::f_url); + return MTP_pageBlockPhoto( + flags, + MTP_long(photoId), + NativeIvCaption(std::move(caption), std::move(credit)), + MTP_string(urlOverride), + MTP_long(0)); +} + +[[nodiscard]] MTPPageBlock NativeIvCoveredPhotoBlock( + uint64 photoId, + QString caption = QString(), + QString credit = QString(), + QString urlOverride = QString()) { + return MTP_pageBlockCover(NativeIvPhotoBlock( + photoId, + std::move(caption), + std::move(credit), + std::move(urlOverride))); +} + +[[nodiscard]] QString NativeIvPlaceholderLabel(NativeIvPlaceholderKind kind) { + switch (kind) { + case NativeIvPlaceholderKind::Video: + return u"Video Placeholder"_q; + case NativeIvPlaceholderKind::Embed: + case NativeIvPlaceholderKind::EmbedPost: + return u"Embed Placeholder"_q; + case NativeIvPlaceholderKind::Collage: + return u"Collage placeholder"_q; + case NativeIvPlaceholderKind::Slideshow: + return u"Grouped Media Placeholder"_q; + case NativeIvPlaceholderKind::Audio: + return u"Audio File Placeholder"_q; + case NativeIvPlaceholderKind::Map: + return u"Map Placeholder"_q; + } + return QString(); +} + +[[nodiscard]] MTPPageBlock NativeIvPlaceholderBlock( + NativeIvPlaceholderKind kind, + QString caption = QString()) { + const auto pageCaption = NativeIvCaption(std::move(caption)); + switch (kind) { + case NativeIvPlaceholderKind::Video: + return MTP_pageBlockVideo( + MTP_flags(0), + MTP_long(7001), + pageCaption); + case NativeIvPlaceholderKind::Embed: + return MTP_pageBlockEmbed( + MTP_flags(0), + MTP_string(), + MTP_string(), + MTP_long(0), + MTP_int(0), + MTP_int(0), + pageCaption); + case NativeIvPlaceholderKind::EmbedPost: + return MTP_pageBlockEmbedPost( + MTP_string("https://example.com/embed-post"), + MTP_long(0), + MTP_long(0), + MTP_string("Author"), + MTP_int(0), + MTP_vector(), + pageCaption); + case NativeIvPlaceholderKind::Collage: + return MTP_pageBlockCollage( + MTP_vector(), + pageCaption); + case NativeIvPlaceholderKind::Slideshow: + return MTP_pageBlockSlideshow( + MTP_vector(), + pageCaption); + case NativeIvPlaceholderKind::Audio: + return MTP_pageBlockAudio(MTP_long(7002), pageCaption); + case NativeIvPlaceholderKind::Map: + return MTP_pageBlockMap( + MTP_geoPointEmpty(), + MTP_int(10), + MTP_int(120), + MTP_int(72), + pageCaption); + } + return MTP_pageBlockUnsupported(); +} + +[[nodiscard]] MTPRichText NativeIvInlineImageText( + uint64 documentId, + int width, + int height) { + return MTP_textImage( + MTP_long(documentId), + MTP_int(width), + MTP_int(height)); +} + +[[nodiscard]] MTPPageBlock NativeIvInlineImageParagraph( + uint64 documentId, + int width, + int height, + QString prefix, + QString suffix) { + return MTP_pageBlockParagraph(NativeIvConcat({ + NativeIvText(std::move(prefix)), + NativeIvInlineImageText(documentId, width, height), + NativeIvText(std::move(suffix)), + })); +} + +[[nodiscard]] Iv::Source NativeIvSource( + QVector blocks, + QVector photos = {}, + QVector documents = {}) { + auto result = Iv::Source(); + result.pageId = 1; + result.page = MTP_page( + MTP_flags(0), + MTP_string("https://example.com/native-iv"), + MTP_vector(std::move(blocks)), + MTP_vector(std::move(photos)), + MTP_vector(std::move(documents)), + MTP_int(0)); + result.name = u"native-iv-test"_q; + return result; +} + [[nodiscard]] QString FromLatin1(const char *value) { return QString::fromLatin1(value); } @@ -1641,6 +1955,635 @@ void CheckInlineTextObjectPrepareCoverage(bool *ok) { } } +void CheckNativeInstantViewPrepareCoverage(bool *ok) { + const auto entityLabel = u"native-iv-inline-image-entity"_q; + const auto serialized = SerializeInlineTextObjectEntity({ + .kind = InlineTextObjectKind::IvImage, + .data = InlineTextObjectIvImageData{ + .documentId = 8801, + .width = 24, + .height = 18, + .replacementText = u"[inline image]"_q, + }, + }); + Check( + !serialized.isEmpty(), + entityLabel + u" serialize"_q, + ok); + const auto parsedEntity = ParseInlineTextObjectEntity(serialized); + Check( + parsedEntity.has_value(), + entityLabel + u" parse"_q, + ok); + if (parsedEntity) { + Check( + parsedEntity->kind == InlineTextObjectKind::IvImage, + entityLabel + u" parsed kind"_q, + ok); + if (const auto image = std::get_if( + &parsedEntity->data)) { + Check( + image->documentId == 8801, + entityLabel + u" parsed document id"_q, + ok); + Check( + image->width == 24 && image->height == 18, + entityLabel + u" parsed size"_q, + ok); + Check( + image->replacementText == u"[inline image]"_q, + entityLabel + u" parsed replacement text"_q, + ok); + } else { + Check(false, entityLabel + u" parsed payload"_q, ok); + } + } + + auto placeholderFixtures = std::vector(); + const auto addPlaceholder = [&]( + NativeIvPlaceholderKind kind, + QString caption) { + placeholderFixtures.push_back({ + .kind = kind, + .caption = caption, + .expectedLabel = NativeIvPlaceholderLabel(kind), + .block = NativeIvPlaceholderBlock(kind, caption), + }); + }; + addPlaceholder(NativeIvPlaceholderKind::Video, u"Video caption"_q); + addPlaceholder(NativeIvPlaceholderKind::Embed, u"Embed caption"_q); + addPlaceholder(NativeIvPlaceholderKind::EmbedPost, u"Embed post caption"_q); + addPlaceholder(NativeIvPlaceholderKind::Collage, u"Collage caption"_q); + addPlaceholder( + NativeIvPlaceholderKind::Slideshow, + u"Grouped caption"_q); + addPlaceholder(NativeIvPlaceholderKind::Audio, u"Audio caption"_q); + addPlaceholder(NativeIvPlaceholderKind::Map, u"Map caption"_q); + + auto supportedBlocks = QVector(); + supportedBlocks.push_back(NativeIvInlineImageParagraph( + 8801, + 24, + 18, + u"Lead "_q, + u" tail."_q)); + supportedBlocks.push_back(NativeIvPhotoBlock( + 9001, + u"Photo caption"_q, + u"Photo credit"_q, + u"https://example.com/photo"_q)); + for (const auto &fixture : placeholderFixtures) { + supportedBlocks.push_back(fixture.block); + } + supportedBlocks.push_back(NativeIvCoveredPhotoBlock( + 9002, + u"Cover caption"_q)); + auto supportedPhotos = QVector(); + supportedPhotos.push_back(NativeIvPhoto(9001, 640, 360)); + supportedPhotos.push_back(NativeIvPhoto(9002, 320, 200)); + auto supportedSource = NativeIvSource( + std::move(supportedBlocks), + std::move(supportedPhotos)); + const auto supported = TryPrepareNativeInstantView({ + .source = &supportedSource, + }); + Check( + supported.supported(), + u"native-iv supported source classification"_q, + ok); + Check( + !supported.unsupported() && !supported.failed(), + u"native-iv supported source nonterminal classification"_q, + ok); + Check( + !supported.content.failure.failed(), + u"native-iv supported source failure state"_q, + ok); + Check( + supported.content.blocks.blocks.size() == 10, + u"native-iv supported prepared block count"_q, + ok); + + const PreparedBlock *inlineParagraph = nullptr; + const PreparedBlock *photo = nullptr; + const PreparedBlock *coveredPhoto = nullptr; + auto preparedPlaceholders = std::vector(); + ForEachPreparedBlock( + supported.content.blocks.blocks, + [&](const PreparedBlock &block) { + if (!inlineParagraph + && block.kind == PreparedBlockKind::Paragraph + && block.text.text.contains(QChar::ObjectReplacementCharacter)) { + inlineParagraph = █ + } + if (!photo + && block.kind == PreparedBlockKind::Photo + && block.photo.photoId == 9001) { + photo = █ + } + if (!coveredPhoto + && block.kind == PreparedBlockKind::Photo + && block.photo.photoId == 9002) { + coveredPhoto = █ + } + if (block.kind == PreparedBlockKind::Placeholder) { + preparedPlaceholders.push_back(&block); + } + }); + Check( + inlineParagraph != nullptr, + u"native-iv inline-image paragraph block"_q, + ok); + if (inlineParagraph) { + const auto matches = CollectInlineTextObjectMatches( + inlineParagraph->text); + Check( + matches.size() == 1, + u"native-iv inline-image entity count"_q, + ok); + Check( + inlineParagraph->text.text.count( + QChar::ObjectReplacementCharacter) == 1, + u"native-iv inline-image ORC count"_q, + ok); + if (matches.size() == 1) { + Check( + matches[0].object.kind == InlineTextObjectKind::IvImage, + u"native-iv inline-image prepared kind"_q, + ok); + if (const auto image = std::get_if( + &matches[0].object.data)) { + Check( + image->documentId == 8801, + u"native-iv inline-image prepared document id"_q, + ok); + Check( + image->width == 24 && image->height == 18, + u"native-iv inline-image prepared size"_q, + ok); + Check( + image->replacementText == u"[image]"_q, + u"native-iv inline-image prepared replacement text"_q, + ok); + } else { + Check(false, u"native-iv inline-image prepared payload"_q, ok); + } + } + } + Check(photo != nullptr, u"native-iv photo block"_q, ok); + if (photo) { + Check( + photo->text.text == u"Photo caption\nPhoto credit"_q, + u"native-iv photo caption text"_q, + ok); + Check( + photo->photo.photoId == 9001, + u"native-iv photo id"_q, + ok); + Check( + photo->photo.width == 640 && photo->photo.height == 360, + u"native-iv photo aspect metadata"_q, + ok); + Check( + photo->photo.urlOverride == u"https://example.com/photo"_q, + u"native-iv photo url override"_q, + ok); + Check( + photo->photo.viewerOpen, + u"native-iv photo viewer flag"_q, + ok); + } + Check( + coveredPhoto != nullptr, + u"native-iv covered photo unwrapped"_q, + ok); + if (coveredPhoto) { + Check( + coveredPhoto->text.text == u"Cover caption"_q, + u"native-iv covered photo caption"_q, + ok); + Check( + coveredPhoto->photo.photoId == 9002, + u"native-iv covered photo id"_q, + ok); + Check( + coveredPhoto->photo.width == 320 && coveredPhoto->photo.height == 200, + u"native-iv covered photo size"_q, + ok); + } + Check( + preparedPlaceholders.size() == placeholderFixtures.size(), + u"native-iv placeholder prepared count"_q, + ok); + for (const auto &fixture : placeholderFixtures) { + const auto it = std::find_if( + preparedPlaceholders.begin(), + preparedPlaceholders.end(), + [&](const PreparedBlock *block) { + return block + && block->placeholder.label == fixture.expectedLabel + && block->text.text == fixture.caption; + }); + Check( + it != preparedPlaceholders.end(), + fixture.expectedLabel + u" prepared placeholder"_q, + ok); + if (it != preparedPlaceholders.end()) { + const auto *block = *it; + Check( + block->placeholder.copyText + == (fixture.expectedLabel + u"\n"_q + fixture.caption), + fixture.expectedLabel + u" placeholder copy text"_q, + ok); + } + } + + auto unsupportedBlocks = QVector(); + unsupportedBlocks.push_back(MTP_pageBlockCover(MTP_pageBlockUnsupported())); + auto unsupportedSource = NativeIvSource(std::move(unsupportedBlocks)); + const auto unsupported = TryPrepareNativeInstantView({ + .source = &unsupportedSource, + }); + Check( + unsupported.supported(), + u"native-iv unsupported block placeholder classification"_q, + ok); + Check( + !unsupported.unsupported() && !unsupported.failed(), + u"native-iv unsupported block nonterminal classification"_q, + ok); + Check( + unsupported.content.blocks.blocks.size() == 1, + u"native-iv unsupported block placeholder count"_q, + ok); + if (unsupported.content.blocks.blocks.size() == 1) { + const auto &block = unsupported.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv unsupported block placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Unsupported Block Placeholder"_q, + u"native-iv unsupported block placeholder label"_q, + ok); + } + + auto missingPhotoBlocks = QVector(); + missingPhotoBlocks.push_back(NativeIvPhotoBlock(9999, u"Missing photo"_q)); + auto missingPhotoSource = NativeIvSource(std::move(missingPhotoBlocks)); + const auto missingPhoto = TryPrepareNativeInstantView({ + .source = &missingPhotoSource, + }); + Check( + missingPhoto.supported(), + u"native-iv missing-photo placeholder classification"_q, + ok); + Check( + !missingPhoto.unsupported() && !missingPhoto.failed(), + u"native-iv missing-photo nonterminal classification"_q, + ok); + Check( + missingPhoto.content.blocks.blocks.size() == 1, + u"native-iv missing-photo placeholder count"_q, + ok); + if (missingPhoto.content.blocks.blocks.size() == 1) { + const auto &block = missingPhoto.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv missing-photo placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Photo Placeholder"_q, + u"native-iv missing-photo placeholder label"_q, + ok); + Check( + block.text.text == u"Missing photo"_q, + u"native-iv missing-photo placeholder caption"_q, + ok); + } +} + +void CheckNativeInstantViewArticleCoverage(bool *ok) { + const auto placeholderLabel = u"native-iv-placeholder-article"_q; + auto placeholderBlocks = QVector(); + placeholderBlocks.push_back(NativeIvPlaceholderBlock( + NativeIvPlaceholderKind::Video, + u"Placeholder caption"_q)); + auto placeholderSource = NativeIvSource(std::move(placeholderBlocks)); + auto placeholderPrepared = TryPrepareNativeInstantView({ + .source = &placeholderSource, + }); + Check( + placeholderPrepared.supported(), + placeholderLabel + u" prepare supported"_q, + ok); + if (placeholderPrepared.supported()) { + auto placeholderHeight = 0; + auto placeholderArticle = BuildArticleForTest( + std::move(placeholderPrepared.content), + std::make_shared(), + 420, + &placeholderHeight); + const auto placeholderImage = PaintArticleForTest( + placeholderArticle.get(), + 420, + placeholderHeight); + Check( + HasPaintedPixels(placeholderImage), + placeholderLabel + u" paint produced pixels"_q, + ok); + const auto placeholderBounds = SegmentHitBounds( + placeholderArticle.get(), + 420, + placeholderHeight, + 0); + Check( + placeholderBounds.has_value(), + placeholderLabel + u" media hit bounds"_q, + ok); + if (placeholderBounds) { + auto flags = Ui::Text::StateRequest::Flags(); + flags |= Ui::Text::StateRequest::Flag::LookupSymbol; + const auto hit = placeholderArticle->hitTest( + placeholderBounds->center(), + flags); + Check( + hit.valid() && hit.direct && (hit.segmentIndex == 0), + placeholderLabel + u" direct media hit"_q, + ok); + Check( + hit.mediaActivation.kind == MediaActivationKind::None, + placeholderLabel + u" no media activation"_q, + ok); + Check( + !placeholderArticle->segmentIsText(0), + placeholderLabel + u" segment is media"_q, + ok); + Check( + placeholderArticle->segmentLength(0) == 1, + placeholderLabel + u" media segment length"_q, + ok); + const auto context = placeholderArticle->textForContext(hit); + Check( + context.expanded + == u"Video Placeholder\nPlaceholder caption"_q, + placeholderLabel + u" context export text"_q, + ok); + const auto selection = placeholderArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + selection.expanded == context.expanded, + placeholderLabel + u" selection export text"_q, + ok); + } + } + + const auto mixedLabel = u"native-iv-mixed-article"_q; + auto mixedRuntime = std::make_shared(); + mixedRuntime->inlineDocumentId = 8801; + mixedRuntime->inlineImage = std::make_shared(); + mixedRuntime->preparedPhotoId = 9101; + mixedRuntime->photoRuntime = std::make_shared(); + mixedRuntime->photoRuntime->thumbnailImage + = std::make_shared(); + mixedRuntime->photoRuntime->fullImage + = std::make_shared(); + mixedRuntime->photoRuntime->loadingValue = true; + mixedRuntime->photoRuntime->progressValue = 0.5; + + auto mixedBlocks = QVector(); + mixedBlocks.push_back(NativeIvInlineImageParagraph( + 8801, + 24, + 18, + u"Intro "_q, + u" tail."_q)); + mixedBlocks.push_back(NativeIvPlaceholderBlock( + NativeIvPlaceholderKind::Video)); + mixedBlocks.push_back(NativeIvPhotoBlock(9101)); + mixedBlocks.push_back(MTP_pageBlockParagraph(NativeIvText( + u"Outro paragraph."_q))); + auto mixedPhotos = QVector(); + mixedPhotos.push_back(NativeIvPhoto(9101, 320, 180)); + auto mixedSource = NativeIvSource( + std::move(mixedBlocks), + std::move(mixedPhotos)); + auto mixedPrepared = TryPrepareNativeInstantView({ + .source = &mixedSource, + .mediaRuntime = mixedRuntime, + }); + Check( + mixedPrepared.supported(), + mixedLabel + u" prepare supported"_q, + ok); + if (!mixedPrepared.supported()) { + return; + } + Check( + mixedPrepared.content.mediaRuntime.get() == mixedRuntime.get(), + mixedLabel + u" media runtime forwarded"_q, + ok); + auto renderer = std::make_shared(); + auto wideHeight = 0; + auto article = BuildArticleForTest( + std::move(mixedPrepared.content), + renderer, + 520, + &wideHeight); + const auto wideImage = PaintArticleForTest(article.get(), 520, wideHeight); + Check( + HasPaintedPixels(wideImage), + mixedLabel + u" wide paint produced pixels"_q, + ok); + Check( + !mixedRuntime->inlineRequests.empty(), + mixedLabel + u" inline image resolve request"_q, + ok); + if (!mixedRuntime->inlineRequests.empty()) { + Check( + mixedRuntime->inlineRequests.front().first == 8801, + mixedLabel + u" inline image resolved document id"_q, + ok); + Check( + mixedRuntime->inlineRequests.front().second == QSize(24, 18), + mixedLabel + u" inline image resolved size"_q, + ok); + } + Check( + !mixedRuntime->photoRequests.empty() + && (mixedRuntime->photoRequests.front() == 9101), + mixedLabel + u" photo runtime resolve request"_q, + ok); + Check( + !mixedRuntime->photoRuntime->thumbnailSizes.empty() + && !mixedRuntime->photoRuntime->fullSizes.empty(), + mixedLabel + u" photo image size requests"_q, + ok); + if (!mixedRuntime->photoRuntime->thumbnailSizes.empty() + && !mixedRuntime->photoRuntime->fullSizes.empty()) { + Check( + mixedRuntime->photoRuntime->thumbnailSizes.front() + == mixedRuntime->photoRuntime->fullSizes.front(), + mixedLabel + u" photo image request sizes match"_q, + ok); + } + Check( + article->segmentIsText(0), + mixedLabel + u" inline-image paragraph remains text segment"_q, + ok); + if (article->segmentIsText(0)) { + const auto exported = article->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { + .segment = 0, + .offset = article->segmentLength(0), + }, + }, nullptr); + Check( + exported.expanded == u"Intro [image] tail."_q, + mixedLabel + u" inline-image export text"_q, + ok); + Check( + !HasEntityType(exported.rich.entities, EntityType::CustomEmoji), + mixedLabel + u" inline-image export drops custom entity"_q, + ok); + } + + const auto narrowHeight = article->resizeGetHeight(260); + const auto narrowImage = PaintArticleForTest( + article.get(), + 260, + narrowHeight); + Check( + HasPaintedPixels(narrowImage), + mixedLabel + u" narrow paint produced pixels"_q, + ok); + Check( + narrowHeight > wideHeight, + mixedLabel + u" narrow relayout grows height"_q, + ok); + Check( + mixedRuntime->inlineImage->subscriptionCount >= 1, + mixedLabel + u" inline image subscribed for repaint"_q, + ok); + Check( + !mixedRuntime->inlineImage->requestedSizes.empty() + && (mixedRuntime->inlineImage->requestedSizes.front() == 24), + mixedLabel + u" inline image paint request size"_q, + ok); + + auto segmentBounds = std::vector>(); + for (auto segmentIndex = 0; segmentIndex != 4; ++segmentIndex) { + segmentBounds.push_back(SegmentHitBounds( + article.get(), + 260, + narrowHeight, + segmentIndex)); + Check( + segmentBounds.back().has_value(), + mixedLabel + u" segment hit bounds "_q + QString::number(segmentIndex), + ok); + } + auto haveBounds = true; + for (const auto &bounds : segmentBounds) { + if (!bounds) { + haveBounds = false; + break; + } + } + if (!haveBounds) { + return; + } + for (auto segmentIndex = 1; segmentIndex != 4; ++segmentIndex) { + Check( + segmentBounds[segmentIndex]->top() + > segmentBounds[segmentIndex - 1]->bottom(), + mixedLabel + u" segment order "_q + QString::number(segmentIndex), + ok); + } + auto flags = Ui::Text::StateRequest::Flags(); + flags |= Ui::Text::StateRequest::Flag::LookupSymbol; + const auto photoHit = article->hitTest(segmentBounds[2]->center(), flags); + Check( + photoHit.valid() && photoHit.direct && (photoHit.segmentIndex == 2), + mixedLabel + u" photo direct hit"_q, + ok); + Check( + photoHit.mediaActivation.kind == MediaActivationKind::Photo, + mixedLabel + u" photo activation kind"_q, + ok); + Check( + !photoHit.preparedLink.has_value(), + mixedLabel + u" photo hit has no external prepared link"_q, + ok); + Check( + photoHit.mediaActivation.photo.get() == mixedRuntime->photoRuntime.get(), + mixedLabel + u" photo runtime forwarded to hit"_q, + ok); + if (photoHit.mediaActivation.photo) { + photoHit.mediaActivation.photo->open(Qt::LeftButton); + } + Check( + mixedRuntime->photoRuntime->openedButtons.size() == 1 + && mixedRuntime->photoRuntime->openedButtons.front() + == Qt::LeftButton, + mixedLabel + u" photo open intent"_q, + ok); + + mixedRuntime->inlineImage->setFrame(SolidTestImage( + 24, + 18, + QColor(0, 160, 220))); + mixedRuntime->inlineImage->notify(); + mixedRuntime->photoRuntime->thumbnailImage->setFrame(SolidTestImage( + 160, + 90, + QColor(220, 160, 0))); + mixedRuntime->photoRuntime->fullImage->setFrame(SolidTestImage( + 320, + 180, + QColor(0, 200, 90))); + mixedRuntime->photoRuntime->loadingValue = false; + mixedRuntime->photoRuntime->loadedValue = true; + mixedRuntime->photoRuntime->progressValue = 1.; + + const auto narrowHeightAfterUpdate = article->resizeGetHeight(260); + Check( + narrowHeightAfterUpdate == narrowHeight, + mixedLabel + u" media repaint keeps layout height"_q, + ok); + const auto updatedImage = PaintArticleForTest( + article.get(), + 260, + narrowHeightAfterUpdate); + Check( + HasPaintedPixels(updatedImage), + mixedLabel + u" updated paint produced pixels"_q, + ok); + const auto updatedPhotoBounds = SegmentHitBounds( + article.get(), + 260, + narrowHeightAfterUpdate, + 2); + const auto updatedInlineBounds = SegmentHitBounds( + article.get(), + 260, + narrowHeightAfterUpdate, + 0); + Check( + updatedPhotoBounds.has_value() + && (*updatedPhotoBounds == *segmentBounds[2]), + mixedLabel + u" photo repaint keeps bounds"_q, + ok); + Check( + updatedInlineBounds.has_value() + && (*updatedInlineBounds == *segmentBounds[0]), + mixedLabel + u" inline-image repaint keeps bounds"_q, + ok); +} + void CheckPrepareCoverage( const PreparedFixture &markdownFixture, const PreparedFixture &latexFixture, @@ -3264,11 +4207,13 @@ ThisIsALongUnbrokenStringToTestWrappingBehavior_ABCD1234EFGH5678IJKL &ok); CheckInlineTextObjectPrepareCoverage(&ok); + CheckNativeInstantViewPrepareCoverage(&ok); CheckPrepareCoverage(markdownFixture, latexFixture, &ok); CheckPrepareLinkClassification(markdownFixture.path, &ok); CheckArticleRenderSmoke(markdownFixture, latexFixture, &ok); CheckArticleHorizontalRelayoutRegression(&ok); CheckInlineTextObjectArticleCoverage(&ok); + CheckNativeInstantViewArticleCoverage(&ok); CheckInlineHtmlCoverage(args.dump, &ok); CheckValidationEdges(&ok);