Support complex history and anchors.

This commit is contained in:
John Preston 2023-12-07 14:37:58 +04:00
parent fae10cfa6b
commit 8b62c37c34
7 changed files with 369 additions and 68 deletions

View file

@ -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);

View file

@ -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 = '<div class="page-slide"><article>'
+ data.html
+ '</article></div>';
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);
}
});

View file

@ -31,6 +31,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include <QtCore/QJsonValue>
#include <QtCore/QFile>
#include <QtGui/QPainter>
#include <charconv>
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 {
<path d="M14.9972363,18 L9.13865768,12.1414214 C9.06055283,12.0633165 9.06055283,11.9366835 9.13865768,11.8585786 L14.9972363,6 L14.9972363,6" transform="translate(11.997236, 12.000000) scale(-1, -1) rotate(-90.000000) translate(-11.997236, -12.000000) "></path>
</svg>
</button>
<article)"_q + contentAttributes + ">"_q + page.content + R"(</article>
<div class="page-scroll"><div class="page-slide">
<article)"_q + contentAttributes + ">"_q + page.content + R"(</article>
</div></div>
<script>)"_q + js + R"(</script>
</body>
</html>
@ -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<QByteArray, rpl::producer<bool>> 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<QByteArray, rpl::producer<bool>> 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<Webview::Window>(
_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<QEvent*> 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<Ui::PopupMenu>(
@ -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 });
});

View file

@ -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<QByteArray, rpl::producer<bool>> 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<Webview::DataRequest> _dataRequests;
rpl::event_stream<Event> _events;
base::flat_map<QByteArray, bool> _inChannelChanged;
base::flat_set<QByteArray> _inChannelSubscribed;
SingleQueuedInvokation _updateStyles;
QString _url;
bool _subscribedToColors = false;
bool _ready = false;
std::vector<Prepared> _pages;
base::flat_map<QString, int> _indices;
QString _navigateToHashWhenReady;
int _navigateToIndexWhenReady = -1;
rpl::lifetime _lifetime;
};

View file

@ -18,6 +18,7 @@ struct Options {
struct Prepared {
QString title;
QByteArray content;
QByteArray script;
QString url;
QString hash;
std::vector<QByteArray> resources;

View file

@ -81,6 +81,8 @@ public:
[[nodiscard]] bool activeFor(not_null<Main::Session*> session) const;
[[nodiscard]] bool active() const;
void moveTo(not_null<Data*> data, QString hash);
void showJoinedTooltip();
void minimize();
@ -115,6 +117,9 @@ private:
std::vector<Webview::DataRequest> requests;
};
void prepare(not_null<Data*> data, const QString &hash);
void createController();
void showLocal(Prepared result);
void showWindowed(Prepared result);
@ -163,6 +168,8 @@ private:
base::flat_map<DocumentId, FileLoad> _files;
base::flat_map<QByteArray, rpl::producer<bool>> _inChannelValues;
bool _preparing = false;
QString _localBase;
base::flat_map<QByteArray, QByteArray> _embeds;
base::flat_map<QString, MapPreview> _maps;
@ -181,15 +188,24 @@ Shown::Shown(
not_null<Data*> data,
QString hash)
: _session(&show->session())
, _show(show)
, _id(data->id()) {
, _show(show) {
prepare(data, hash);
}
void Shown::prepare(not_null<Data*> 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>();
_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*> 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*> 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<Shown>(show, data, hash);

View file

@ -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) {