diff --git a/resources/js/headerAnchor.js b/resources/js/headerAnchor.js index 044d9cc56..c704c16c5 100644 --- a/resources/js/headerAnchor.js +++ b/resources/js/headerAnchor.js @@ -43,11 +43,114 @@ function getHeadersJSONStr() return JSON.stringify(headerInfo); } -new QWebChannel(qt.webChannelTransport, function(channel) { - var kiwixObj = channel.objects.kiwixChannelObj; - kiwixObj.sendHeadersJSONStr(getHeadersJSONStr()); - kiwixObj.navigationRequested.connect(function(url, anchor) { - if (window.location.href.replace(location.hash,"") == url) - document.getElementById(anchor).scrollIntoView(); - }); -}); +// Track the current anchor for history state +let currentAnchor = null; +let isNavigating = false; + +// Function to scroll to an anchor with animation +function scrollToAnchor(anchor, updateHistory = false) { + if (!anchor || typeof anchor !== 'string') { + console.error("Invalid anchor:", anchor); + return false; + } + + if (isNavigating || anchor === currentAnchor) { + console.log("Already navigating to or at anchor: " + anchor); + return true; + } + + try { + isNavigating = true; + const element = document.getElementById(anchor); + if (element) { + setTimeout(() => { + element.scrollIntoView({behavior: 'smooth'}); + currentAnchor = anchor; + + // Update the URL in history if requested + if (updateHistory && window.history && window.history.pushState) { + try { + const baseUrl = window.location.href.replace(location.hash, ""); + window.history.pushState({ anchor: anchor }, "", baseUrl + "#" + anchor); + } catch (historyError) { + console.error("Error updating history:", historyError); + } + } + + // Reset navigation flag after a short delay + setTimeout(() => { + isNavigating = false; + }, 100); + }, 10); + + return true; + } + console.error("Anchor not found: " + anchor); + isNavigating = false; + return false; + } catch (error) { + console.error("Error scrolling to anchor:", error); + isNavigating = false; + return false; + } +} + +function initializeWebChannel() { + try { + if (typeof qt === 'undefined' || typeof qt.webChannelTransport === 'undefined') { + console.error("Qt WebChannel not available"); + return; + } + + new QWebChannel(qt.webChannelTransport, function(channel) { + if (!channel || !channel.objects || !channel.objects.kiwixChannelObj) { + console.error("Kiwix channel object not available"); + return; + } + + var kiwixObj = channel.objects.kiwixChannelObj; + + try { + kiwixObj.sendHeadersJSONStr(getHeadersJSONStr()); + } catch (e) { + console.error("Error sending headers:", e); + } + + kiwixObj.navigationRequested.connect(function(url, anchor) { + if (isNavigating || anchor === currentAnchor) { + return; + } + + if (window.location.href.replace(location.hash, "") == url) { + scrollToAnchor(anchor, false); + } + }); + + window.addEventListener('popstate', function(event) { + if (isNavigating) { + return; + } + + // Handle navigation from history + if (location.hash) { + const anchor = location.hash.substring(1); + if (anchor !== currentAnchor) { + scrollToAnchor(anchor, false); + } + } else if (event.state && event.state.anchor) { + if (event.state.anchor !== currentAnchor) { + scrollToAnchor(event.state.anchor, false); + } + } + }); + }); + } catch (error) { + console.error("Error initializing web channel:", error); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeWebChannel); +} else { + initializeWebChannel(); +} \ No newline at end of file diff --git a/src/kiwixwebchannelobject.h b/src/kiwixwebchannelobject.h index 9e37d38da..ba383252a 100644 --- a/src/kiwixwebchannelobject.h +++ b/src/kiwixwebchannelobject.h @@ -11,10 +11,12 @@ class KiwixWebChannelObject : public QObject explicit KiwixWebChannelObject(QObject *parent = nullptr) : QObject(parent) {}; Q_INVOKABLE void sendHeadersJSONStr(const QString& headersJSONStr) { emit headersChanged(headersJSONStr); }; + Q_INVOKABLE void sendConsoleMessage(const QString& message) { emit consoleMessageReceived(message); }; signals: void headersChanged(const QString& headersJSONStr); void navigationRequested(const QString& url, const QString& anchor); + void consoleMessageReceived(const QString& message); }; -#endif // KIWIXWEBCHANNELOBJECT_H +#endif // KIWIXWEBCHANNELOBJECT_H \ No newline at end of file diff --git a/src/tableofcontentbar.cpp b/src/tableofcontentbar.cpp index 78531db34..04803cd3f 100644 --- a/src/tableofcontentbar.cpp +++ b/src/tableofcontentbar.cpp @@ -3,6 +3,7 @@ #include "kiwixapp.h" #include #include +#include TableOfContentBar::TableOfContentBar(QWidget *parent) : QFrame(parent), @@ -24,6 +25,10 @@ TableOfContentBar::TableOfContentBar(QWidget *parent) : ui->tree->setItemsExpandable(false); connect(ui->tree, &QTreeWidget::itemClicked, this, &TableOfContentBar::onTreeItemActivated); connect(ui->tree, &QTreeWidget::itemActivated, this, &TableOfContentBar::onTreeItemActivated); + + // Setup debounce timer + m_clickDebounceTimer.setSingleShot(true); + m_clickDebounceTimer.setInterval(300); // 300ms debounce } TableOfContentBar::~TableOfContentBar() @@ -33,7 +38,37 @@ TableOfContentBar::~TableOfContentBar() void TableOfContentBar::onTreeItemActivated(QTreeWidgetItem *item) { - emit navigationRequested(m_url, item->data(0, Qt::UserRole).toString()); + //Safety check + if (!item) { + return; + } + + // Get the anchor from the item + QVariant anchorVariant = item->data(0, Qt::UserRole); + if (!anchorVariant.isValid()) { + return; + } + + QString anchor = anchorVariant.toString(); + if (anchor.isEmpty() || m_url.isEmpty()) { + return; + } + + if (m_isNavigating || (anchor == m_lastAnchor && m_clickDebounceTimer.isActive())) { + return; + } + + m_isNavigating = true; + m_lastAnchor = anchor; + m_clickDebounceTimer.start(); + + QTimer::singleShot(10, this, [this, anchor]() { + emit navigationRequested(m_url, anchor); + + QTimer::singleShot(300, this, [this]() { + m_isNavigating = false; + }); + }); } namespace @@ -93,9 +128,40 @@ void TableOfContentBar::setupTree(const QJsonObject& headers) const auto currentUrl = webView->url().url(QUrl::RemoveFragment); if (headerUrl != currentUrl) return; - + m_url = headerUrl; ui->tree->clear(); QJsonArray headerArr = headers["headers"].toArray(); createSubTree(ui->tree->invisibleRootItem(), "", headerArr); + + // Update selection based on current URL fragment + updateSelectionFromFragment(webView->url().fragment()); } + +void TableOfContentBar::updateSelectionFromFragment(const QString& fragment) +{ + if (fragment.isEmpty() || !ui || !ui->tree) { + return; + } + + // Find the item with the matching anchor + QTreeWidgetItemIterator it(ui->tree); + while (*it) { + QVariant anchorVariant = (*it)->data(0, Qt::UserRole); + if (!anchorVariant.isValid()) { + ++it; + continue; + } + + QString anchor = anchorVariant.toString(); + if (anchor == fragment) { + // Select the item without triggering navigation + ui->tree->blockSignals(true); + ui->tree->setCurrentItem(*it); + ui->tree->scrollToItem(*it); + ui->tree->blockSignals(false); + break; + } + ++it; + } +} \ No newline at end of file diff --git a/src/tableofcontentbar.h b/src/tableofcontentbar.h index c2a2356a5..c019f45b7 100644 --- a/src/tableofcontentbar.h +++ b/src/tableofcontentbar.h @@ -2,6 +2,7 @@ #define TABLEOFCONTENTBAR_H #include +#include namespace Ui { class tableofcontentbar; @@ -20,6 +21,7 @@ class TableOfContentBar : public QFrame public slots: void setupTree(const QJsonObject& headers); void onTreeItemActivated(QTreeWidgetItem* item); + void updateSelectionFromFragment(const QString& fragment); signals: void navigationRequested(const QString& url, const QString& anchor); @@ -27,6 +29,9 @@ public slots: private: Ui::tableofcontentbar *ui; QString m_url; + QTimer m_clickDebounceTimer; + bool m_isNavigating = false; + QString m_lastAnchor; }; -#endif // TABLEOFCONTENTBAR_H +#endif // TABLEOFCONTENTBAR_H \ No newline at end of file diff --git a/src/webview.cpp b/src/webview.cpp index 638b80a8f..43c5ff582 100644 --- a/src/webview.cpp +++ b/src/webview.cpp @@ -20,6 +20,7 @@ class QMenu; #include #include "kiwixwebchannelobject.h" #include "tableofcontentbar.h" +#include zim::Entry getArchiveEntryFromUrl(const zim::Archive& archive, const QUrl& url); QString askForSaveFilePath(const QString& suggestedName); @@ -84,6 +85,7 @@ WebView::WebView(QWidget *parent) { setPage(new WebPage(this)); QObject::connect(this, &QWebEngineView::urlChanged, this, &WebView::onUrlChanged); + QObject::connect(this, &QWebEngineView::urlChanged, this, &WebView::handleTocHistoryNavigation); connect(this->page(), &QWebEnginePage::linkHovered, this, [=] (const QString& url) { m_linkHovered = url; }); @@ -106,10 +108,14 @@ WebView::WebView(QWidget *parent) const auto kiwixChannelObj = new KiwixWebChannelObject; page()->setWebChannel(channel, QWebEngineScript::UserWorld); channel->registerObject("kiwixChannelObj", kiwixChannelObj); - + const auto tabbar = KiwixApp::instance()->getTabWidget(); connect(tabbar, &TabBar::currentTitleChanged, this, &WebView::onCurrentTitleChanged); connect(kiwixChannelObj, &KiwixWebChannelObject::headersChanged, this, &WebView::onHeadersReceived); + connect(kiwixChannelObj, &KiwixWebChannelObject::navigationRequested, + this, &WebView::onNavigationRequested); + connect(kiwixChannelObj, &KiwixWebChannelObject::consoleMessageReceived, + this, &WebView::onConsoleMessageReceived); const auto tocbar = KiwixApp::instance()->getMainWindow()->getTableOfContentBar(); connect(this, &WebView::headersChanged, tocbar, &TableOfContentBar::setupTree); @@ -223,18 +229,106 @@ void WebView::onCurrentTitleChanged() void WebView::onHeadersReceived(const QString& headersJSONStr) { - const auto tabbar = KiwixApp::instance()->getTabWidget(); - m_headers = QJsonDocument::fromJson(headersJSONStr.toUtf8()).object(); - - if (tabbar->currentWebView() == this) + QJsonDocument doc = QJsonDocument::fromJson(headersJSONStr.toUtf8()); + if (!doc.isObject()) + return; + + m_headers = doc.object(); + if (KiwixApp::instance()->getTabWidget()->currentWebView() == this) emit headersChanged(m_headers); } +void WebView::onConsoleMessageReceived(const QString& message) +{ + // Parse the JSON message + QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8()); + if (!doc.isObject()) + return; + + QJsonObject obj = doc.object(); + QString type = obj["type"].toString(); + QString msg = obj["message"].toString(); + + // Log to console for debugging + qDebug() << "JS Console [" << type << "]: " << msg; +} + void WebView::onNavigationRequested(const QString &url, const QString &anchor) { + // Safety checks const auto tabbar = KiwixApp::instance()->getTabWidget(); - if (tabbar->currentWebView() == this) - emit navigationRequested(url, anchor); + if (!tabbar || tabbar->currentWebView() != this) { + return; + } + + // Check if we're already at this anchor + if (this->url().hasFragment() && this->url().fragment() == anchor) { + // Already at this anchor, no need to navigate + return; + } + + // Create a QUrl with fragment for history + QUrl historyUrl(url); + historyUrl.setFragment(anchor); + + // Properly escape the URL and anchor for JavaScript + QString escapedUrl = url.toHtmlEscaped(); + QString escapedAnchor = anchor.toHtmlEscaped(); + + // Use a simpler approach to avoid navigation loops + QString js = QString( + "if (window.history && window.history.pushState) {" + " if (document.getElementById('%1')) {" + " window.history.pushState({anchor: '%1'}, '', '%2#%1');" + " console.log('History updated for anchor: %1');" + " } else {" + " console.error('Anchor not found: %1');" + " }" + "}" + ).arg(escapedAnchor).arg(escapedUrl); + + // Execute JavaScript safely with a callback + page()->runJavaScript(js, [this, url, anchor](const QVariant &result) { + Q_UNUSED(result); + // Emit the navigation signal to the JavaScript after the history is updated + // Use a small delay to prevent navigation loops + QTimer::singleShot(50, this, [this, url, anchor]() { + emit navigationRequested(url, anchor); + }); + }); +} + +// Add a method to handle history navigation for TOC entries +void WebView::handleTocHistoryNavigation(const QUrl &url) +{ + // Safety check + if (!url.isValid()) { + return; + } + + // If the URL has a fragment and the base URL matches the current page + if (url.hasFragment() && url.url(QUrl::RemoveFragment) == this->url().url(QUrl::RemoveFragment)) { + // Extract the anchor from the fragment + QString anchor = url.fragment(); + + // Safety check for empty anchor + if (anchor.isEmpty()) { + return; + } + + // Check if we're already at this anchor to prevent loops + if (this->url().hasFragment() && this->url().fragment() == anchor) { + return; + } + + // Use a small delay to prevent navigation loops + QTimer::singleShot(50, this, [this, url, anchor]() { + // Emit navigation signal instead of loading the page + emit navigationRequested(url.url(QUrl::RemoveFragment), anchor); + }); + + return; + } } void WebView::addHistoryItemAction(QMenu *menu, @@ -277,6 +371,13 @@ void WebView::onUrlChanged(const QUrl& url) { auto app = KiwixApp::instance(); app->saveListOfOpenTabs(); if (m_currentZimId == zimId ) { + // Even if the ZIM ID hasn't changed, we still need to update TOC selection + if (url.hasFragment()) { + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + tocBar->updateSelectionFromFragment(url.fragment()); + } + } return; } m_currentZimId = zimId; @@ -421,4 +522,4 @@ bool WebView::event(QEvent *event) return QWebEngineView::event(event); } return true; -} +} \ No newline at end of file diff --git a/src/webview.h b/src/webview.h index d65639207..97402f1f6 100644 --- a/src/webview.h +++ b/src/webview.h @@ -72,7 +72,9 @@ private slots: void gotoTriggeredHistoryItemAction(); void onCurrentTitleChanged(); void onHeadersReceived(const QString& headersJSONStr); + void onConsoleMessageReceived(const QString& message); void onNavigationRequested(const QString& url, const QString& anchor); + void handleTocHistoryNavigation(const QUrl& url); private: void addHistoryItemAction(QMenu *menu, const QWebEngineHistoryItem &item, int n) const; @@ -82,4 +84,4 @@ private slots: QJsonObject m_headers; }; -#endif // WEBVIEW_H +#endif // WEBVIEW_H \ No newline at end of file