From 8672b2ae11f1efe282cc7f6abe293fc22750ec29 Mon Sep 17 00:00:00 2001 From: Jinash-Rouniyar Date: Thu, 27 Feb 2025 03:36:17 -0500 Subject: [PATCH 1/2] Fix: Record TOC navigations in history (#1248) --- resources/js/headerAnchor.js | 119 ++++++++++++++++++++++++++++++++--- src/kiwixwebchannelobject.h | 4 +- src/tableofcontentbar.cpp | 70 ++++++++++++++++++++- src/tableofcontentbar.h | 7 ++- src/webview.cpp | 117 +++++++++++++++++++++++++++++++--- src/webview.h | 4 +- 6 files changed, 300 insertions(+), 21 deletions(-) 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 19cd84e10..df5d8ad41 100644 --- a/src/webview.cpp +++ b/src/webview.cpp @@ -21,6 +21,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); @@ -85,6 +86,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; }); @@ -107,10 +109,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); @@ -224,18 +230,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, @@ -278,6 +372,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; @@ -429,4 +530,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 From d236e53c39c0630daf65a99ff340de0531eecec5 Mon Sep 17 00:00:00 2001 From: Jinash-Rouniyar Date: Sat, 8 Mar 2025 11:15:15 -0500 Subject: [PATCH 2/2] Fix: Record TOC navigations in history (#1248) --- resources/js/headerAnchor.js | 98 ++++++++-------- src/tabbar.cpp | 41 ++++++- src/tableofcontentbar.cpp | 1 + src/webview.cpp | 217 +++++++++++++++++++++++++++++------ src/webview.h | 9 ++ 5 files changed, 282 insertions(+), 84 deletions(-) diff --git a/resources/js/headerAnchor.js b/resources/js/headerAnchor.js index c704c16c5..dcec14e2c 100644 --- a/resources/js/headerAnchor.js +++ b/resources/js/headerAnchor.js @@ -1,23 +1,23 @@ -function isHeaderElement(elem) -{ +// Track the current anchor for history state +let currentAnchor = null; +let isNavigating = false; + +function isHeaderElement(elem) { return elem.nodeName.match(/^H\d+$/) && elem.textContent; } -function getDOMElementsPreorderDFS(elem, pred) -{ +function getDOMElementsPreorderDFS(elem, pred) { var result = []; if (pred(elem)) result.push(elem); - for ( const child of elem.children) + for (const child of elem.children) result = result.concat(getDOMElementsPreorderDFS(child, pred)); return result; } -function anchorHeaderElements(headers) -{ - return Array.from(headers, function(elem, i) - { +function anchorHeaderElements(headers) { + return Array.from(headers, function(elem, i) { const text = elem.textContent.trim().replace(/"/g, '\\"'); const level = parseInt(elem.nodeName.substr(1)); const anchor = `kiwix-toc-${i}`; @@ -25,38 +25,35 @@ function anchorHeaderElements(headers) const anchorElem = document.createElement("a"); anchorElem.id = anchor; - /* Mark header content with something we can reference. */ + // Mark header content with something we can reference elem.insertAdjacentElement("afterbegin", anchorElem); return { text, level, anchor }; }); } -function getHeadersJSONStr() -{ +function getHeadersJSONStr() { const headerInfo = { url: window.location.href.replace(location.hash,""), headers: [] }; - if (document.body !== undefined) - { + if (document.body !== undefined) { const headers = getDOMElementsPreorderDFS(document.body, isHeaderElement); headerInfo.headers = anchorHeaderElements(headers); } return JSON.stringify(headerInfo); } -// 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; + // Skip if already navigating to the same anchor, unless triggered by history + if (isNavigating && anchor === currentAnchor && !updateHistory) { + // Continue if from history navigation + const isFromHistory = document.referrer === '' || + (window.history.state && window.history.state.anchor === anchor); + if (!isFromHistory) { + return true; + } } try { @@ -72,24 +69,20 @@ function scrollToAnchor(anchor, updateHistory = false) { try { const baseUrl = window.location.href.replace(location.hash, ""); window.history.pushState({ anchor: anchor }, "", baseUrl + "#" + anchor); - } catch (historyError) { - console.error("Error updating history:", historyError); - } + } catch (e) { } } // Reset navigation flag after a short delay setTimeout(() => { isNavigating = false; - }, 100); + }, 150); }, 10); return true; } - console.error("Anchor not found: " + anchor); isNavigating = false; return false; - } catch (error) { - console.error("Error scrolling to anchor:", error); + } catch (e) { isNavigating = false; return false; } @@ -98,13 +91,11 @@ function scrollToAnchor(anchor, updateHistory = 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; } @@ -112,12 +103,12 @@ function initializeWebChannel() { try { kiwixObj.sendHeadersJSONStr(getHeadersJSONStr()); - } catch (e) { - console.error("Error sending headers:", e); - } + } catch (e) { } + // Handle navigation requests from Qt kiwixObj.navigationRequested.connect(function(url, anchor) { - if (isNavigating || anchor === currentAnchor) { + // Skip if already navigating to the same anchor + if (isNavigating && anchor === currentAnchor) { return; } @@ -126,29 +117,46 @@ function initializeWebChannel() { } }); + // Handle browser history navigation (back/forward buttons) window.addEventListener('popstate', function(event) { if (isNavigating) { return; } // Handle navigation from history + let anchorFound = false; if (location.hash) { const anchor = location.hash.substring(1); - if (anchor !== currentAnchor) { - scrollToAnchor(anchor, false); - } + anchorFound = scrollToAnchor(anchor, false); } else if (event.state && event.state.anchor) { - if (event.state.anchor !== currentAnchor) { - scrollToAnchor(event.state.anchor, false); - } + anchorFound = scrollToAnchor(event.state.anchor, false); + } + + // Notify Qt about the navigation to update TOC + if (anchorFound && kiwixObj) { + setTimeout(function() { + try { + const currentHash = location.hash ? location.hash.substring(1) : + (event.state && event.state.anchor ? event.state.anchor : null); + + if (currentHash) { + kiwixObj.sendConsoleMessage(JSON.stringify({ + type: "history-navigation", + message: "Browser history navigation event", + anchor: currentHash, + url: window.location.href, + timestamp: Date.now() + })); + } + } catch (e) { } + }, 150); } }); }); - } catch (error) { - console.error("Error initializing web channel:", error); - } + } catch (e) { } } +// Initialize web channel when document is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeWebChannel); } else { diff --git a/src/tabbar.cpp b/src/tabbar.cpp index 3c7632e09..01128bd79 100644 --- a/src/tabbar.cpp +++ b/src/tabbar.cpp @@ -4,6 +4,7 @@ class QMenu; #include "kiwixapp.h" #include "css_constants.h" +#include "tableofcontentbar.h" #include #include #include @@ -11,6 +12,7 @@ class QMenu; #include #include #include +#include #define QUITIFNULL(VIEW) if (nullptr==(VIEW)) { return; } #define CURRENTIFNULL(VIEW) if(nullptr==VIEW) { VIEW = currentZimView();} @@ -269,8 +271,43 @@ void TabBar::triggerWebPageAction(QWebEnginePage::WebAction action, ZimView *wid { CURRENTIFNULL(widget); QUITIFNULL(widget); - widget->getWebView()->triggerPageAction(action); - widget->getWebView()->setFocus(); + + auto webView = widget->getWebView(); + + // For back/forward navigation, use direct JavaScript history methods + if (action == QWebEnginePage::Back || action == QWebEnginePage::Forward) { + // Instead of using Qt's action, directly use JavaScript to manipulate browser history + // This ensures consistent behavior across platforms and better TOC synchronization + if (action == QWebEnginePage::Back) { + webView->page()->runJavaScript("window.history.back();"); + } else { + webView->page()->runJavaScript("window.history.forward();"); + } + + // Focus the webview + webView->setFocus(); + + // Set up a check to ensure the TOC is updated after navigation + QTimer::singleShot(300, widget, [widget]() { + auto webView = widget->getWebView(); + if (!webView) return; + + QString fragment = webView->url().fragment(); + if (!fragment.isEmpty()) { + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + tocBar->updateSelectionFromFragment(fragment); + } + } + }); + + return; + } + + // For other actions, just trigger them normally + webView->triggerPageAction(action); + webView->setFocus(); } void TabBar::closeTabsByZimId(const QString &id) diff --git a/src/tableofcontentbar.cpp b/src/tableofcontentbar.cpp index 04803cd3f..6fc2d00d9 100644 --- a/src/tableofcontentbar.cpp +++ b/src/tableofcontentbar.cpp @@ -4,6 +4,7 @@ #include #include #include +#include TableOfContentBar::TableOfContentBar(QWidget *parent) : QFrame(parent), diff --git a/src/webview.cpp b/src/webview.cpp index df5d8ad41..30bc51749 100644 --- a/src/webview.cpp +++ b/src/webview.cpp @@ -87,6 +87,31 @@ WebView::WebView(QWidget *parent) setPage(new WebPage(this)); QObject::connect(this, &QWebEngineView::urlChanged, this, &WebView::onUrlChanged); QObject::connect(this, &QWebEngineView::urlChanged, this, &WebView::handleTocHistoryNavigation); + + // Inject JavaScript to handle popstate events for better history navigation + connect(page(), &QWebEnginePage::loadStarted, this, [=]() { + // Inject a popstate event handler to ensure smooth navigation and scrolling + QString js = + "if (!window._kiwixHandlerInstalled) {" + " window._kiwixHandlerInstalled = true;" + " try {" + " window.addEventListener('popstate', function(event) {" + " if (location.hash) {" + " var anchorId = location.hash.substring(1);" + " var element = document.getElementById(anchorId);" + " if (element) {" + " element.scrollIntoView({behavior: 'smooth'});" + " }" + " }" + " });" + " } catch(e) {" + " console.error('Error in popstate handler:', e);" + " }" + "}"; + + page()->runJavaScript(js); + }); + connect(this->page(), &QWebEnginePage::linkHovered, this, [=] (const QString& url) { m_linkHovered = url; }); @@ -122,6 +147,41 @@ WebView::WebView(QWidget *parent) connect(this, &WebView::headersChanged, tocbar, &TableOfContentBar::setupTree); connect(tocbar, &TableOfContentBar::navigationRequested, this, &WebView::onNavigationRequested); connect(this, &WebView::navigationRequested, kiwixChannelObj, &KiwixWebChannelObject::navigationRequested); + + // Add this in the WebView constructor + connect(this, &QWebEngineView::loadFinished, this, [this](bool success) { + if (success) { + // Update history action states + auto app = KiwixApp::instance(); + app->getAction(KiwixApp::HistoryBackAction)->setEnabled(history()->canGoBack()); + app->getAction(KiwixApp::HistoryForwardAction)->setEnabled(history()->canGoForward()); + + // This will catch ALL successful page loads, including history navigation + // Update TOC selection if URL has fragment + if (url().hasFragment()) { + QString fragment = url().fragment(); + qDebug() << "Page load finished - Updating TOC selection for fragment:" << fragment; + + // Use a slightly longer delay for more reliability + QTimer::singleShot(250, this, [this, fragment]() { + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + qDebug() << "Applying TOC selection update for fragment:" << fragment; + tocBar->updateSelectionFromFragment(fragment); + + // Force another scroll to the anchor to ensure it's visible + page()->runJavaScript(QString( + "if (document.getElementById('%1')) {" + " console.log('Page loaded - Ensuring scroll to anchor: %1');" + " document.getElementById('%1').scrollIntoView({behavior: 'smooth'});" + "}" + ).arg(fragment)); + } + }); + } + } + }); } WebView::~WebView() @@ -250,8 +310,20 @@ void WebView::onConsoleMessageReceived(const QString& message) QString type = obj["type"].toString(); QString msg = obj["message"].toString(); - // Log to console for debugging - qDebug() << "JS Console [" << type << "]: " << msg; + // Special handling for history navigation messages + if (type == "history-navigation") { + // Check if we have an anchor in the JSON + if (obj.contains("anchor") && obj["anchor"].isString()) { + QString anchor = obj["anchor"].toString(); + + if (!anchor.isEmpty()) { + // Sync TOC selection with the current fragment + QTimer::singleShot(10, this, [this, anchor]() { + syncTOCWithFragment(anchor); + }); + } + } + } } void WebView::onNavigationRequested(const QString &url, const QString &anchor) @@ -262,32 +334,42 @@ void WebView::onNavigationRequested(const QString &url, const QString &anchor) return; } + // Create a QUrl with fragment for history + QUrl historyUrl(url); + historyUrl.setFragment(anchor); + + // Update TOC selection + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + tocBar->updateSelectionFromFragment(anchor); + } + // 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');" + // JS code to avoid Unexpected end of input error + QString js = QString( + "try {" + " if (window.history && window.history.pushState) {" + " var elem = document.getElementById('%1');" + " if (elem) {" + " window.history.pushState({anchor: '%1'}, '', '%2#%1');" + " elem.scrollIntoView({behavior: 'smooth'});" + " } else {" + " console.error('Anchor not found: %1');" + " }" " }" + "} catch(e) {" + " console.error('Navigation error:', e);" "}" ).arg(escapedAnchor).arg(escapedUrl); - // Execute JavaScript safely with a callback page()->runJavaScript(js, [this, url, anchor](const QVariant &result) { Q_UNUSED(result); @@ -307,28 +389,33 @@ void WebView::handleTocHistoryNavigation(const QUrl &url) 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 + // For any URL with a fragment, update TOC selection + if (url.hasFragment()) { 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; + + // Update TOC selection for any URL with a fragment + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + tocBar->updateSelectionFromFragment(anchor); + } + + // Only for URLs on the current page, emit navigation signal + if (url.url(QUrl::RemoveFragment) == this->url().url(QUrl::RemoveFragment)) { + // Only emit navigation signal if we're not already at this anchor + if (!(this->url().hasFragment() && this->url().fragment() == anchor)) { + // 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); + }); + } } - - // 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; } } @@ -352,7 +439,25 @@ void WebView::gotoTriggeredHistoryItemAction() if (n < 0 || n >= h->count()) return; - h->goToItem(h->itemAt(n)); + // Store the item before navigating + QWebEngineHistoryItem item = h->itemAt(n); + + // If the target URL has a fragment, update TOC after navigation + if (item.url().hasFragment()) { + QString fragment = item.url().fragment(); + + // After navigation, update the TOC selection + QTimer::singleShot(200, this, [this, fragment]() { + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + tocBar->updateSelectionFromFragment(fragment); + } + }); + } + + // Go to the history item + h->goToItem(item); } @@ -374,10 +479,12 @@ void WebView::onUrlChanged(const QUrl& url) { 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()); - } + QString fragment = url.fragment(); + + // Sync TOC selection after a short delay to ensure the page has loaded + QTimer::singleShot(100, this, [this, fragment]() { + syncTOCWithFragment(fragment); + }); } return; } @@ -387,6 +494,10 @@ void WebView::onUrlChanged(const QUrl& url) { auto zoomFactor = app->getSettingsManager()->getZoomFactorByZimId(zimId); this->setZoomFactor(zoomFactor); emit iconChanged(m_icon); + + // Update history action states + app->getAction(KiwixApp::HistoryBackAction)->setEnabled(history()->canGoBack()); + app->getAction(KiwixApp::HistoryForwardAction)->setEnabled(history()->canGoForward()); } void WebView::wheelEvent(QWheelEvent *event) { @@ -530,4 +641,36 @@ bool WebView::event(QEvent *event) return QWebEngineView::event(event); } return true; +} + +void WebView::syncTOCWithFragment(const QString& fragment) +{ + if (fragment.isEmpty()) { + return; + } + + // Get the TOC bar and update selection + auto app = KiwixApp::instance(); + auto tocBar = app->getMainWindow()->getTableOfContentBar(); + if (tocBar) { + // Update the TOC selection to match the current fragment + tocBar->updateSelectionFromFragment(fragment); + + // Ensure the fragment is properly scrolled into view + QString escapedFragment = fragment.toHtmlEscaped(); + QString js = QString( + "try {" + " var element = document.getElementById('%1');" + " if (element) {" + " element.scrollIntoView({behavior: 'smooth'});" + " setTimeout(function() {" + " element.scrollIntoView({behavior: 'smooth'});" + " }, 100);" + " }" + "} catch(e) { console.error('Error in syncTOCWithFragment:', e); }" + ).arg(escapedFragment); + + // Execute the JavaScript + page()->runJavaScript(js); + } } \ No newline at end of file diff --git a/src/webview.h b/src/webview.h index 97402f1f6..84c5b2a13 100644 --- a/src/webview.h +++ b/src/webview.h @@ -50,6 +50,15 @@ class WebView : public QWebEngineView public slots: void onUrlChanged(const QUrl& url); + + /** + * @brief Synchronizes TOC selection with the given fragment + * @param fragment The anchor ID to select in the TOC + * + * This method updates the TOC selection and ensures the selected element + * is properly scrolled into view in the browser. + */ + void syncTOCWithFragment(const QString& fragment); signals: void iconChanged(const QIcon& icon);