From 8b62c37c3449c8945921eca43e9f0bf8daf09a7a Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 7 Dec 2023 14:37:58 +0400 Subject: [PATCH] Support complex history and anchors. --- Telegram/Resources/iv_html/page.css | 29 +++- Telegram/Resources/iv_html/page.js | 201 ++++++++++++++++++++-- Telegram/SourceFiles/iv/iv_controller.cpp | 145 +++++++++++----- Telegram/SourceFiles/iv/iv_controller.h | 18 +- Telegram/SourceFiles/iv/iv_data.h | 1 + Telegram/SourceFiles/iv/iv_instance.cpp | 41 ++++- Telegram/SourceFiles/iv/iv_prepare.cpp | 2 +- 7 files changed, 369 insertions(+), 68 deletions(-) diff --git a/Telegram/Resources/iv_html/page.css b/Telegram/Resources/iv_html/page.css index 856922333..1da34f68a 100644 --- a/Telegram/Resources/iv_html/page.css +++ b/Telegram/Resources/iv_html/page.css @@ -128,9 +128,33 @@ html.custom_scroll ::-webkit-scrollbar-thumb:hover { bottom: -36px; } +.page-scroll { + position: absolute; + width: 100%; + height: 100%; + overflow-x: hidden; + overflow-y: auto; +} +.page-slide { + position: relative; + width: 100%; + margin-left: 0%; + transition: margin 240ms ease-in-out; +} +.hidden-left, +.hidden-right { + pointer-events: none; +} +.hidden-left .page-slide { + margin-left: -100%; +} +.hidden-right .page-slide { + margin-left: 100%; +} article { padding-bottom: 12px; - overflow: hidden; + overflow-y: hidden; + overflow-x: auto; white-space: pre-wrap; max-width: 732px; margin: 0 auto; @@ -150,6 +174,9 @@ article h2 { margin: -6px 18px 12px; color: var(--td-window-sub-text-fg); } +article h5 { + margin: 21px 18px 12px; +} article address { font-size: 15px; color: var(--td-window-sub-text-fg); diff --git a/Telegram/Resources/iv_html/page.js b/Telegram/Resources/iv_html/page.js index dca833fee..1e15dd90d 100644 --- a/Telegram/Resources/iv_html/page.js +++ b/Telegram/Resources/iv_html/page.js @@ -35,20 +35,31 @@ var IV = { context: context, }); } else if (target.hash.length < 2) { - IV.hash = ''; - IV.scrollTo(0); + IV.jumpToHash(''); } else { - const name = target.hash.substr(1); - IV.hash = name; - - const element = document.getElementsByName(name)[0]; - if (element) { - const y = element.getBoundingClientRect().y; - IV.scrollTo(y + document.documentElement.scrollTop); - } + IV.jumpToHash(target.hash.substr(1)); } e.preventDefault(); }, + jumpToHash: function (hash, instant) { + var current = IV.computeCurrentState(); + current.hash = hash; + window.history.replaceState(current, ''); + if (hash == '') { + IV.scrollTo(0, instant); + return; + } + + var element = document.getElementsByName(hash)[0]; + if (element) { + var y = 0; + while (element && !element.classList.contains('page-scroll')) { + y += element.offsetTop; + element = element.offsetParent; + } + IV.scrollTo(y, instant); + } + }, frameKeyDown: function (e) { const keyW = (e.key === 'w') || (e.code === 'KeyW') @@ -210,7 +221,7 @@ var IV = { } }, init: function () { - IV.hash = window.location.hash.substr(1); + window.history.replaceState(IV.computeCurrentState(), ''); const buttons = document.getElementsByClassName('fixed_button'); for (let i = 0; i < buttons.length; ++i) { @@ -228,6 +239,10 @@ var IV = { IV.stopRipples(e.currentTarget); }); } + IV.initMedia(); + IV.notify({ event: 'ready' }); + }, + initMedia: function () { const photos = document.getElementsByClassName('photo'); for (let i = 0; i < photos.length; ++i) { const photo = photos[i]; @@ -258,7 +273,6 @@ var IV = { video.classList.add('loaded'); }); } - IV.notify({ event: 'ready' }); }, showTooltip: function (text) { var toast = document.createElement('div'); @@ -272,14 +286,163 @@ var IV = { document.body.removeChild(toast); }, 3000); }, - scrollTo: function (y) { + scrollTo: function (y, instant) { document.getElementById('bottom_up').classList.add('hidden'); - window.scrollTo({ top: y || 0, behavior: 'smooth' }); + IV.findPageScroll().scrollTo({ + top: y || 0, + behavior: instant ? 'instant' : 'smooth' + }); }, menu: function (button) { IV.frozenRipple = button.id; - IV.notify({ event: 'menu', hash: IV.hash }); - } + const state = this.computeCurrentState(); + IV.notify({ event: 'menu', index: state.index, hash: state.hash }); + }, + + computeCurrentState: function () { + var now = IV.findPageScroll(); + return { + position: IV.position, + index: IV.index, + hash: ((!window.history.state + || window.history.state.hash === undefined) + ? window.location.hash.substr(1) + : window.history.state.hash), + scroll: now ? now.scrollTop : 0 + }; + }, + navigateTo: function (index, hash) { + if (!index && !IV.index) { + IV.navigateToDOM(IV.index, hash); + return; + } + IV.pending = [index, hash]; + if (!IV.cache[index]) { + IV.cache[index] = { loading: true }; + + let xhr = new XMLHttpRequest(); + xhr.onload = function () { + IV.cache[index].loading = false; + IV.cache[index].content = xhr.responseText; + if (IV.pending && IV.pending[0] == index) { + IV.navigateToLoaded(index, IV.pending[1]); + } + } + + xhr.open('GET', 'page' + index + '.json'); + xhr.send(); + } else if (IV.cache[index].dom) { + IV.navigateToDOM(index, hash); + } else if (IV.cache[index].content) { + IV.navigateToLoaded(index, hash); + } + }, + + navigateToLoaded: function (index, hash) { + if (IV.cache[index].dom) { + IV.navigateToDOM(index, hash); + } else { + var data = JSON.parse(IV.cache[index].content); + var el = document.createElement('div'); + el.className = 'page-scroll'; + el.innerHTML = '
' + + data.html + + '
'; + IV.cache[index].dom = el; + + IV.navigateToDOM(index, hash); + eval(data.js); + } + }, + navigateToDOM: function (index, hash) { + IV.pending = null; + if (IV.index == index) { + IV.jumpToHash(hash); + return; + } + window.history.replaceState(IV.computeCurrentState(), ''); + + IV.position = IV.position + 1; + window.history.pushState( + { position: IV.position, index: index, hash: hash }, + '', + 'page' + index + '.html' + (hash.length ? '#' + hash : '')); + IV.showDOM(index, hash); + }, + findPageScroll: function () { + var all = document.getElementsByClassName('page-scroll'); + for (i = 0; i < all.length; ++i) { + if (!all[i].classList.contains('hidden-left') + && !all[i].classList.contains('hidden-right')) { + return all[i]; + } + } + return null; + }, + showDOM: function (index, hash, scroll) { + IV.pending = null; + if (IV.index != index) { + var initial = !window.history.state + || window.history.state.position === undefined; + var back = initial + || IV.position > window.history.state.position; + IV.position = initial ? 0 : window.history.state.position; + + var now = IV.cache[index].dom; + var was = IV.findPageScroll(); + if (!IV.cache[IV.index]) { + IV.cache[IV.index] = {}; + } + IV.cache[IV.index].dom = was; + was.parentNode.appendChild(now); + if (scroll !== undefined) { + now.scrollTop = scroll; + } + + now.classList.add(back ? 'hidden-left' : 'hidden-right'); + now.classList.remove(back ? 'hidden-right' : 'hidden-left'); + now.firstChild.getAnimations().forEach( + (animation) => animation.finish()); + + if (!was.listening) { + was.listening = true; + was.firstChild.addEventListener('transitionend', function (e) { + if (was.classList.contains('hidden-left') + || was.classList.contains('hidden-right')) { + if (was.parentNode) { + was.parentNode.removeChild(was); + } + } + }); + } + + was.classList.add(back ? 'hidden-right' : 'hidden-left'); + now.classList.remove(back ? 'hidden-left' : 'hidden-right'); + + var topBack = document.getElementById('top_back'); + if (!IV.position) { + topBack.classList.add('hidden'); + } else { + topBack.classList.remove('hidden'); + } + IV.index = index; + IV.initMedia(); + if (scroll === undefined) { + IV.jumpToHash(hash, true); + } + } else if (scroll !== undefined) { + IV.scrollTo(scroll); + } else { + IV.jumpToHash(hash); + } + }, + back: function () { + window.history.back(); + }, + + cache: {}, + index: 0, + position: 0 }; document.onclick = IV.frameClickHandler; @@ -288,3 +451,9 @@ document.onmouseenter = IV.frameMouseEnter; document.onmouseup = IV.frameMouseUp; document.onscroll = IV.frameScrolled; window.onmessage = IV.postMessageHandler; + +window.addEventListener('popstate', function (e) { + if (e.state) { + IV.showDOM(e.state.index, e.state.hash, e.state.scroll); + } +}); diff --git a/Telegram/SourceFiles/iv/iv_controller.cpp b/Telegram/SourceFiles/iv/iv_controller.cpp index dca23189c..d2f299121 100644 --- a/Telegram/SourceFiles/iv/iv_controller.cpp +++ b/Telegram/SourceFiles/iv/iv_controller.cpp @@ -31,6 +31,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include +#include namespace Iv { namespace { @@ -130,9 +131,7 @@ namespace { .replace('\'', "\\\'"); } -[[nodiscard]] QByteArray WrapPage( - const Prepared &page, - const QByteArray &initScript) { +[[nodiscard]] QByteArray WrapPage(const Prepared &page) { #ifdef Q_OS_MAC const auto classAttribute = ""_q; #else // Q_OS_MAC @@ -143,7 +142,7 @@ namespace { + (page.hasCode ? "IV.initPreBlocks();" : "") + (page.hasEmbeds ? "IV.initEmbedBlocks();" : "") + "IV.init();" - + initScript; + + page.script; const auto contentAttributes = page.rtl ? " dir=\"rtl\" class=\"rtl\""_q @@ -183,7 +182,9 @@ namespace { - "_q + page.content + R"( +
+ "_q + page.content + R"( +
@@ -199,28 +200,29 @@ Controller::Controller() _webview->eval("IV.updateStyles('" + str + "');"); } }) { + createWindow(); } Controller::~Controller() { + _window->hide(); _ready = false; _webview = nullptr; _title = nullptr; _window = nullptr; } +bool Controller::showFast(const QString &url, const QString &hash) { + return false; +} + void Controller::show( const QString &dataPath, Prepared page, base::flat_map> inChannelValues) { - createWindow(); - const auto js = fillInChannelValuesScript(std::move(inChannelValues)); - + page.script = fillInChannelValuesScript(std::move(inChannelValues)); _titleText.setText(st::ivTitle.style, page.title); InvokeQueued(_container, [=, page = std::move(page)]() mutable { - showInWindow(dataPath, std::move(page), js); - if (!_webview) { - return; - } + showInWindow(dataPath, std::move(page)); }); } @@ -228,13 +230,15 @@ QByteArray Controller::fillInChannelValuesScript( base::flat_map> inChannelValues) { auto result = QByteArray(); for (auto &[id, in] : inChannelValues) { - std::move(in) | rpl::start_with_next([=](bool in) { - if (_ready) { - _webview->eval(toggleInChannelScript(id, in)); - } else { - _inChannelChanged[id] = in; - } - }, _lifetime); + if (_inChannelSubscribed.emplace(id).second) { + std::move(in) | rpl::start_with_next([=](bool in) { + if (_ready) { + _webview->eval(toggleInChannelScript(id, in)); + } else { + _inChannelChanged[id] = in; + } + }, _lifetime); + } } for (const auto &[id, in] : base::take(_inChannelChanged)) { result += toggleInChannelScript(id, in); @@ -342,14 +346,10 @@ void Controller::createWindow() { window->show(); } -void Controller::showInWindow( - const QString &dataPath, - Prepared page, - const QByteArray &initScript) { - Expects(_container != nullptr); +void Controller::createWebview(const QString &dataPath) { + Expects(!_webview); const auto window = _window.get(); - _url = page.url; _webview = std::make_unique( _container, Webview::WindowConfig{ @@ -362,10 +362,7 @@ void Controller::showInWindow( _ready = false; _webview = nullptr; }); - if (!raw->widget()) { - _events.fire({ Event::Type::Close }); - return; - } + window->events( ) | rpl::start_with_next([=](not_null e) { if (e->type() == QEvent::Close) { @@ -411,11 +408,18 @@ void Controller::showInWindow( for (const auto &[id, in] : base::take(_inChannelChanged)) { script += toggleInChannelScript(id, in); } + if (_navigateToIndexWhenReady >= 0) { + script += navigateScript( + std::exchange(_navigateToIndexWhenReady, -1), + base::take(_navigateToHashWhenReady)); + } if (!script.isEmpty()) { _webview->eval(script); } } else if (event == u"menu"_q) { - menu(object.value("hash").toString()); + menu( + object.value("index").toInt(), + object.value("hash").toString()); } }); }); @@ -433,7 +437,7 @@ void Controller::showInWindow( return Webview::DataResult::Done; }; const auto id = std::string_view(request.id).substr(3); - if (id == "page.html") { + if (id.starts_with("page") && id.ends_with(".html")) { if (!_subscribedToColors) { _subscribedToColors = true; @@ -444,7 +448,33 @@ void Controller::showInWindow( _updateStyles.call(); }, _webview->lifetime()); } - return finishWith(WrapPage(page, initScript), "text/html"); + auto index = 0; + const auto result = std::from_chars( + id.data() + 4, + id.data() + id.size() - 5, + index); + if (result.ec != std::errc() + || index < 0 + || index >= _pages.size()) { + return Webview::DataResult::Failed; + } + return finishWith(WrapPage(_pages[index]), "text/html"); + } else if (id.starts_with("page") && id.ends_with(".json")) { + auto index = 0; + const auto result = std::from_chars( + id.data() + 4, + id.data() + id.size() - 5, + index); + if (result.ec != std::errc() + || index < 0 + || index >= _pages.size()) { + return Webview::DataResult::Failed; + } + auto &page = _pages[index]; + return finishWith(QJsonDocument(QJsonObject{ + { "html", QJsonValue(QString::fromUtf8(page.content)) }, + { "js", QJsonValue(QString::fromUtf8(page.script)) }, + }).toJson(QJsonDocument::Compact), "application/json"); } const auto css = id.ends_with(".css"); const auto js = !css && id.ends_with(".js"); @@ -464,12 +494,48 @@ void Controller::showInWindow( }); raw->init(R"()"); +} - auto id = u"iv/page.html"_q; - if (!page.hash.isEmpty()) { - id += '#' + page.hash; +void Controller::showInWindow(const QString &dataPath, Prepared page) { + Expects(_container != nullptr); + + const auto url = page.url; + const auto hash = page.hash; + auto i = _indices.find(url); + if (i == end(_indices)) { + _pages.push_back(std::move(page)); + i = _indices.emplace(url, int(_pages.size() - 1)).first; } - raw->navigateToData(id); + const auto index = i->second; + if (!_webview) { + createWebview(dataPath); + if (_webview && _webview->widget()) { + auto id = u"iv/page%1.html"_q.arg(index); + if (!hash.isEmpty()) { + id += '#' + hash; + } + _webview->navigateToData(id); + } else { + _events.fire({ Event::Type::Close }); + } + } else if (_ready) { + _webview->eval(navigateScript(index, hash)); + _window->activateWindow(); + _window->setFocus(); + } else { + _navigateToIndexWhenReady = index; + _navigateToHashWhenReady = hash; + _window->activateWindow(); + _window->setFocus(); + } +} + +QByteArray Controller::navigateScript(int index, const QString &hash) { + return "IV.navigateTo(" + + QByteArray::number(index) + + ", '" + + EscapeForScriptString(hash.toUtf8()) + + "');"; } void Controller::processKey(const QString &key, const QString &modifier) { @@ -537,8 +603,8 @@ void Controller::minimize() { } } -void Controller::menu(const QString &hash) { - if (!_webview || _menu) { +void Controller::menu(int index, const QString &hash) { + if (!_webview || _menu || index < 0 || index > _pages.size()) { return; } _menu = base::make_unique_q( @@ -552,7 +618,8 @@ void Controller::menu(const QString &hash) { } })); - const auto url = _url + (hash.isEmpty() ? u""_q : ('#' + hash)); + const auto url = _pages[index].url + + (hash.isEmpty() ? u""_q : ('#' + hash)); const auto openInBrowser = crl::guard(_window.get(), [=] { _events.fire({ .type = Event::Type::OpenLinkExternal, .url = url }); }); diff --git a/Telegram/SourceFiles/iv/iv_controller.h b/Telegram/SourceFiles/iv/iv_controller.h index f0b77276e..36b2f14c4 100644 --- a/Telegram/SourceFiles/iv/iv_controller.h +++ b/Telegram/SourceFiles/iv/iv_controller.h @@ -50,6 +50,7 @@ public: QString context; }; + [[nodiscard]] bool showFast(const QString &url, const QString &hash); void show( const QString &dataPath, Prepared page, @@ -70,12 +71,12 @@ public: private: void createWindow(); + void createWebview(const QString &dataPath); + [[nodiscard]] QByteArray navigateScript(int index, const QString &hash); + void updateTitleGeometry(); void paintTitle(Painter &p, QRect clip); - void showInWindow( - const QString &dataPath, - Prepared page, - const QByteArray &initScript); + void showInWindow(const QString &dataPath, Prepared page); [[nodiscard]] QByteArray fillInChannelValuesScript( base::flat_map> inChannelValues); [[nodiscard]] QByteArray toggleInChannelScript( @@ -85,7 +86,7 @@ private: void processKey(const QString &key, const QString &modifier); void processLink(const QString &url, const QString &context); - void menu(const QString &hash); + void menu(int index, const QString &hash); void escape(); void close(); void quit(); @@ -101,11 +102,16 @@ private: rpl::event_stream _dataRequests; rpl::event_stream _events; base::flat_map _inChannelChanged; + base::flat_set _inChannelSubscribed; SingleQueuedInvokation _updateStyles; - QString _url; bool _subscribedToColors = false; bool _ready = false; + std::vector _pages; + base::flat_map _indices; + QString _navigateToHashWhenReady; + int _navigateToIndexWhenReady = -1; + rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/iv/iv_data.h b/Telegram/SourceFiles/iv/iv_data.h index 9340c511f..f995c2c3f 100644 --- a/Telegram/SourceFiles/iv/iv_data.h +++ b/Telegram/SourceFiles/iv/iv_data.h @@ -18,6 +18,7 @@ struct Options { struct Prepared { QString title; QByteArray content; + QByteArray script; QString url; QString hash; std::vector resources; diff --git a/Telegram/SourceFiles/iv/iv_instance.cpp b/Telegram/SourceFiles/iv/iv_instance.cpp index b4539da6e..b62e0d5b3 100644 --- a/Telegram/SourceFiles/iv/iv_instance.cpp +++ b/Telegram/SourceFiles/iv/iv_instance.cpp @@ -81,6 +81,8 @@ public: [[nodiscard]] bool activeFor(not_null session) const; [[nodiscard]] bool active() const; + void moveTo(not_null data, QString hash); + void showJoinedTooltip(); void minimize(); @@ -115,6 +117,9 @@ private: std::vector requests; }; + void prepare(not_null data, const QString &hash); + void createController(); + void showLocal(Prepared result); void showWindowed(Prepared result); @@ -163,6 +168,8 @@ private: base::flat_map _files; base::flat_map> _inChannelValues; + bool _preparing = false; + QString _localBase; base::flat_map _embeds; base::flat_map _maps; @@ -181,15 +188,24 @@ Shown::Shown( not_null data, QString hash) : _session(&show->session()) -, _show(show) -, _id(data->id()) { +, _show(show) { + prepare(data, hash); +} + +void Shown::prepare(not_null data, const QString &hash) { const auto weak = base::make_weak(this); + _preparing = true; + const auto id = _id = data->id(); const auto base = /*local ? LookupLocalPath(show) : */QString(); data->prepare({ .saveToFolder = base }, [=](Prepared result) { result.hash = hash; crl::on_main(weak, [=, result = std::move(result)]() mutable { - result.url = _id; + result.url = id; + if (_id != id || !_preparing) { + return; + } + _preparing = false; _embeds = std::move(result.embeds); fillChannelJoinedValues(result); if (!base.isEmpty()) { @@ -416,7 +432,9 @@ void Shown::writeEmbed(QString id, QString hash) { } } -void Shown::showWindowed(Prepared result) { +void Shown::createController() { + Expects(!_controller); + _controller = std::make_unique(); _controller->events( @@ -436,6 +454,12 @@ void Shown::showWindowed(Prepared result) { sendEmbed(id.mid(5).toUtf8(), std::move(request)); } }, _controller->lifetime()); +} + +void Shown::showWindowed(Prepared result) { + if (!_controller) { + createController(); + } const auto domain = &_session->domain(); _controller->show( @@ -753,6 +777,12 @@ bool Shown::active() const { return _controller && _controller->active(); } +void Shown::moveTo(not_null data, QString hash) { + if (!_controller || !_controller->showFast(data->id(), hash)) { + prepare(data, hash); + } +} + void Shown::showJoinedTooltip() { if (_controller) { _controller->showJoinedTooltip(); @@ -774,7 +804,8 @@ void Instance::show( not_null data, QString hash) { const auto session = &show->session(); - if (_shown && _shown->showing(session, data)) { + if (_shown && _shownSession == session) { + _shown->moveTo(data, hash); return; } _shown = std::make_unique(show, data, hash); diff --git a/Telegram/SourceFiles/iv/iv_prepare.cpp b/Telegram/SourceFiles/iv/iv_prepare.cpp index a9186997f..3f1e63fff 100644 --- a/Telegram/SourceFiles/iv/iv_prepare.cpp +++ b/Telegram/SourceFiles/iv/iv_prepare.cpp @@ -744,7 +744,7 @@ QByteArray Parser::block(const MTPDpageBlockAudio &data) { } QByteArray Parser::block(const MTPDpageBlockKicker &data) { - return tag("h6", { { "class", "kicker" } }, rich(data.vtext())); + return tag("h5", { { "class", "kicker" } }, rich(data.vtext())); } QByteArray Parser::block(const MTPDpageBlockTable &data) {