Skip to content

Fix: Record TOC navigations in history (#1248) #1349

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 133 additions & 22 deletions resources/js/headerAnchor.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,164 @@
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}`;

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

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();
});
});
function scrollToAnchor(anchor, updateHistory = false) {
if (!anchor || typeof anchor !== 'string') {
return false;
}

// 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 {
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 (e) { }
}

// Reset navigation flag after a short delay
setTimeout(() => {
isNavigating = false;
}, 150);
}, 10);

return true;
}
isNavigating = false;
return false;
} catch (e) {
isNavigating = false;
return false;
}
}

function initializeWebChannel() {
try {
if (typeof qt === 'undefined' || typeof qt.webChannelTransport === 'undefined') {
return;
}

new QWebChannel(qt.webChannelTransport, function(channel) {
if (!channel || !channel.objects || !channel.objects.kiwixChannelObj) {
return;
}

var kiwixObj = channel.objects.kiwixChannelObj;

try {
kiwixObj.sendHeadersJSONStr(getHeadersJSONStr());
} catch (e) { }

// Handle navigation requests from Qt
kiwixObj.navigationRequested.connect(function(url, anchor) {
// Skip if already navigating to the same anchor
if (isNavigating && anchor === currentAnchor) {
return;
}

if (window.location.href.replace(location.hash, "") == url) {
scrollToAnchor(anchor, false);
}
});

// 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);
anchorFound = scrollToAnchor(anchor, false);
} else if (event.state && event.state.anchor) {
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 (e) { }
}

// Initialize web channel when document is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeWebChannel);
} else {
initializeWebChannel();
}
4 changes: 3 additions & 1 deletion src/kiwixwebchannelobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 39 additions & 2 deletions src/tabbar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ class QMenu;

#include "kiwixapp.h"
#include "css_constants.h"
#include "tableofcontentbar.h"
#include <QAction>
#include <QTimer>
#include <QWebEnginePage>
#include <QToolButton>
#include <QToolTip>
#include <QCursor>
#include <QPainter>
#include <QDebug>
#define QUITIFNULL(VIEW) if (nullptr==(VIEW)) { return; }
#define CURRENTIFNULL(VIEW) if(nullptr==VIEW) { VIEW = currentZimView();}

Expand Down Expand Up @@ -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)
Expand Down
71 changes: 69 additions & 2 deletions src/tableofcontentbar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "kiwixapp.h"
#include <QJsonObject>
#include <QTreeWidgetItem>
#include <QTimer>
#include <QDebug>

TableOfContentBar::TableOfContentBar(QWidget *parent) :
QFrame(parent),
Expand All @@ -24,6 +26,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()
Expand All @@ -33,7 +39,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
Expand Down Expand Up @@ -93,9 +129,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;
}
}
7 changes: 6 additions & 1 deletion src/tableofcontentbar.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#define TABLEOFCONTENTBAR_H

#include <QFrame>
#include <QTimer>

namespace Ui {
class tableofcontentbar;
Expand All @@ -20,13 +21,17 @@ 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);

private:
Ui::tableofcontentbar *ui;
QString m_url;
QTimer m_clickDebounceTimer;
bool m_isNavigating = false;
QString m_lastAnchor;
};

#endif // TABLEOFCONTENTBAR_H
#endif // TABLEOFCONTENTBAR_H
Loading