diff --git a/kiwix-desktop.pro b/kiwix-desktop.pro index 8d370af0..296db7d4 100644 --- a/kiwix-desktop.pro +++ b/kiwix-desktop.pro @@ -5,7 +5,7 @@ #------------------------------------------------- QT += core gui network -QT += webenginewidgets +QT += webenginewidgets webchannel QT += printsupport # Avoid stripping incompatible files, due to false identification as executables, on WSL @@ -85,6 +85,7 @@ SOURCES += \ src/tabbar.cpp \ src/contentmanagerside.cpp \ src/readinglistbar.cpp \ + src/tableofcontentbar.cpp \ src/klistwidgetitem.cpp \ src/opdsrequestmanager.cpp \ src/localkiwixserver.cpp \ @@ -135,6 +136,7 @@ HEADERS += \ src/tabbar.h \ src/contentmanagerside.h \ src/readinglistbar.h \ + src/tableofcontentbar.h \ src/klistwidgetitem.h \ src/opdsrequestmanager.h \ src/localkiwixserver.h \ @@ -145,6 +147,7 @@ HEADERS += \ src/portutils.h \ src/css_constants.h \ src/multizimbutton.h \ + src/kiwixwebchannelobject.h \ FORMS += \ src/choiceitem.ui \ @@ -157,7 +160,8 @@ FORMS += \ src/contentmanagerside.ui \ src/readinglistbar.ui \ ui/localkiwixserver.ui \ - ui/settings.ui + ui/settings.ui \ + src/tableofcontentbar.ui \ include(subprojects/QtSingleApplication/src/qtsingleapplication.pri) CODECFORSRC = UTF-8 @@ -224,6 +228,7 @@ RESOURCES += \ resources/translations.qrc \ resources/contentmanager.qrc \ resources/settingsmanager.qrc \ - resources/style.qrc + resources/style.qrc \ + resources/js.qrc RC_ICONS = resources/icons/kiwix/app_icon.ico diff --git a/resources/css/style.css b/resources/css/style.css index e3b08c85..c0b10a68 100644 --- a/resources/css/style.css +++ b/resources/css/style.css @@ -400,3 +400,71 @@ ContentTypeFilter { width: 0; height: 0; } + +#tableofcontentbar { + background-color: white; +} + +#tableofcontentbar QTreeWidget, +#tableofcontentbar QLabel, +#tableofcontentbar QFrame { + background-color: white; +} + +#tableofcontentbar QTreeWidget { + outline: none; +} + +#tableofcontentbar QTreeWidget::item { + height: 26px; + padding: 0px 10px; + outline: none; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; +} + +#tableofcontentbar QTreeWidget::item:selected, +#tableofcontentbar QTreeWidget::item:hover { + outline: none; + border-top: 1px solid #3366CC; + border-bottom: 1px solid #3366CC; + background-color: #D9E9FF; + color: black; +} + +#tableofcontentbar QTreeWidget::branch:selected, +#tableofcontentbar QTreeWidget::branch:hover { + outline: none; + border-top: 1px solid #3366CC; + border-bottom: 1px solid #3366CC; + background-color: #D9E9FF; +} + +#tableofcontentbar QTreeWidget::branch:has-children:closed { + padding: 5px; /* Can only change icon size with padding. */ + image: url(:/icons/caret-up-solid.svg); +} + +#tableofcontentbar QTreeWidget::branch:has-children { + padding: 5px; /* Can only change icon size with padding. */ + image: url(:/icons/caret-down-solid.svg); +} + +#tableofcontentbar #titleLabel { + padding: 0px; + margin: 10px; +} + +#tableofcontentbar #hideLabel { + margin: 13px 10px 10px; /* 3px to match bottom with titleLabel */ +} + +#tableofcontentbar QScrollBar { + width: 5px; + border: none; + outline: none; +} + +#tableofcontentbar QScrollBar::handle { + background-color: grey; +} diff --git a/resources/js.qrc b/resources/js.qrc new file mode 100644 index 00000000..39a8210a --- /dev/null +++ b/resources/js.qrc @@ -0,0 +1,5 @@ + + + js/tableofcontent.js + + diff --git a/resources/js/tableofcontent.js b/resources/js/tableofcontent.js new file mode 100644 index 00000000..8ada9c6e --- /dev/null +++ b/resources/js/tableofcontent.js @@ -0,0 +1,75 @@ +/** + * Construct recurseData.str as a JSON Array that contains their header text, + * link, and children headers. + * + * References: + * https://stackoverflow.com/questions/187619/is-there-a-javascript-solution-to-generating-a-table-of-contents-for-a-page + * @param elem DOM element + * @param recurseData Object with fields: { level : int, toc : str, count : int, Set : levelSet } + */ +function recurseChild(elem, recurseData) +{ + if (elem !== "undefined") + { + if(elem.nodeName.match(/^H\d+$/) && elem.textContent) + { + var headerText = elem.textContent.trim(); + var prevLevel = recurseData.level; + var level = elem.nodeName.substr(1); + var anchor = "kiwix-toc-" + recurseData.count; + var anchorLink = window.location.href.replace(location.hash,"") + '#' + anchor; + recurseData.count += 1; + + var anchorElem = document.createElement("a"); + anchorElem.id = anchor; + + /* Wrap header content with something we can reference. */ + elem.insertAdjacentElement("afterbegin", anchorElem); + + if (level < prevLevel) + { + /* Complete current element and the parent element.*/ + recurseData.toc += ']}]}, '; + } + else if (level == prevLevel) + { + /* Complete current element*/ + recurseData.toc += ']}, '; + } + + recurseData.level = parseInt(level); + recurseData.levelSet.add(parseInt(level)); + recurseData.toc += '{"text" : "' + headerText.replace(/"/g, '\\"') + '", "anchor": "' + anchorLink + '", ' + '"child" : ['; + } + + var c = elem.children; + for (var i = 0; i < c.length; i++) + recurseChild(c[i], recurseData); + } +} + +function tocJSON() +{ + /* level used to track current header level. + toc used to store constructed list. + count used to uniquely identify each list item in toc. + levelSet used to retrieve the levels disregarding header level value. + */ + var recurseData = { level: 0, toc: '{ "url" : "' + window.location.href.replace(location.hash,"") + '", "table" : [', count: 0, levelSet: new Set}; + recurseChild(document.body, recurseData); + + var levelArray = Array.from(recurseData.levelSet).sort(); + levelArray.sort(function(a, b){return a - b}); + var level = levelArray.indexOf(recurseData.level) + 1; + + /* End un-closed lists */ + if (level) + recurseData.toc += (new Array(level + 1)).join(']}'); + recurseData.toc += ']}'; + return recurseData.toc; +} + +new QWebChannel(qt.webChannelTransport, function(channel) { + var kiwixObj = channel.objects.kiwixChannelObj; + kiwixObj.sendTableOfContent(tocJSON()); +}); diff --git a/src/kiwixapp.cpp b/src/kiwixapp.cpp index ed29b526..0404d6de 100644 --- a/src/kiwixapp.cpp +++ b/src/kiwixapp.cpp @@ -435,8 +435,8 @@ void KiwixApp::createActions() }); mpa_actions[ToggleFullscreenAction]->setCheckable(true); - CREATE_ACTION_SHORTCUT(ToggleTOCAction, gt("table-of-content"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_1)); - HIDE_ACTION(ToggleTOCAction); + CREATE_ACTION_ICON_SHORTCUT(ToggleTOCAction, "toc", gt("table-of-content"), QKeySequence(Qt::CTRL | Qt::Key_M)); + mpa_actions[ToggleTOCAction]->setCheckable(true); CREATE_ACTION_ICON_SHORTCUT(OpenMultiZimAction, "filter", gt("search-options"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_L)); @@ -492,6 +492,7 @@ void KiwixApp::handleItemsState(TabType tabType) auto libraryOrSettingsTab = (tabType == TabType::LibraryTab || tabType == TabType::SettingsTab); auto notBookmarkableTab = libraryOrSettingsTab || getTabWidget()->currentArticleUrl().isEmpty(); auto app = KiwixApp::instance(); + app->getAction(KiwixApp::ToggleTOCAction)->setDisabled(libraryOrSettingsTab); app->getAction(KiwixApp::ToggleReadingListAction)->setDisabled(libraryOrSettingsTab); app->getAction(KiwixApp::ToggleAddBookmarkAction)->setDisabled(notBookmarkableTab); app->getAction(KiwixApp::FindInPageAction)->setDisabled(libraryOrSettingsTab); diff --git a/src/kiwixwebchannelobject.h b/src/kiwixwebchannelobject.h new file mode 100644 index 00000000..f14e94d0 --- /dev/null +++ b/src/kiwixwebchannelobject.h @@ -0,0 +1,19 @@ +#ifndef KIWIXWEBCHANNELOBJECT_H +#define KIWIXWEBCHANNELOBJECT_H + +#include + +class KiwixWebChannelObject : public QObject +{ + Q_OBJECT + +public: + explicit KiwixWebChannelObject(QObject *parent = nullptr) : QObject(parent) {}; + + Q_INVOKABLE void sendTableOfContent(const QString& tableJson) { emit tableOfContentChanged(tableJson); }; + +signals: + void tableOfContentChanged(const QString& tableJson); +}; + +#endif // KIWIXWEBCHANNELOBJECT_H diff --git a/src/kprofile.cpp b/src/kprofile.cpp index 6659ebbd..3aba83c2 100644 --- a/src/kprofile.cpp +++ b/src/kprofile.cpp @@ -4,6 +4,26 @@ #include #include #include +#include +#include + +namespace +{ + +QWebEngineScript getScript(QString filename, + QWebEngineScript::InjectionPoint point = QWebEngineScript::DocumentReady) +{ + QWebEngineScript script; + script.setInjectionPoint(point); + script.setWorldId(QWebEngineScript::UserWorld); + + QFile scriptFile(filename); + scriptFile.open(QIODevice::ReadOnly); + script.setSourceCode(scriptFile.readAll()); + return script; +} + +} QString askForSaveFilePath(const QString& suggestedName) { @@ -36,6 +56,10 @@ KProfile::KProfile(QObject *parent) : #else // Qt 5.13 and later setUrlRequestInterceptor(new ExternalReqInterceptor(this)); #endif + + scripts()->insert(getScript(":/js/tableofcontent.js")); + scripts()->insert(getScript(":/qtwebchannel/qwebchannel.js", + QWebEngineScript::DocumentCreation)); } #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6b2feb85..a298e9c2 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -45,6 +45,8 @@ MainWindow::MainWindow(QWidget *parent) : this, &MainWindow::toggleFullScreen); connect(app->getAction(KiwixApp::ToggleReadingListAction), &QAction::toggled, this, &MainWindow::readingListToggled); + connect(app->getAction(KiwixApp::ToggleTOCAction), &QAction::toggled, + this, &MainWindow::tableOfContentToggled); connect(app->getAction(KiwixApp::AboutAction), &QAction::triggered, mp_about, &QDialog::show); connect(app->getAction(KiwixApp::DonateAction), &QAction::triggered, @@ -170,9 +172,18 @@ void MainWindow::resizeEvent(QResizeEvent *event) updateTabButtons(); } +void checkActionNoSignal(KiwixApp::Actions actionFlag, bool checked) +{ + const auto action = KiwixApp::instance()->getAction(actionFlag); + const bool oldState = action->blockSignals(true); + action->setChecked(checked); + action->blockSignals(oldState); +} + void MainWindow::readingListToggled(bool state) { if (state) { + checkActionNoSignal(KiwixApp::ToggleTOCAction, false); mp_ui->sideBar->setCurrentWidget(mp_ui->readinglistbar); mp_ui->sideBar->show(); } @@ -181,16 +192,33 @@ void MainWindow::readingListToggled(bool state) } } +void MainWindow::tableOfContentToggled(bool state) +{ + if (state) { + checkActionNoSignal(KiwixApp::ToggleReadingListAction, false); + mp_ui->sideBar->setCurrentWidget(mp_ui->tableofcontentbar); + mp_ui->sideBar->show(); + } + else { + mp_ui->sideBar->hide(); + } +} + void MainWindow::tabChanged(TabType tabType) { QAction *readingList = KiwixApp::instance()->getAction(KiwixApp::ToggleReadingListAction); + QAction *tableOfContent = KiwixApp::instance()->getAction(KiwixApp::ToggleTOCAction); if (tabType == TabType::SettingsTab) { mp_ui->sideBar->hide(); } else if(tabType == TabType::LibraryTab) { mp_ui->sideBar->setCurrentWidget(mp_ui->contentmanagerside); mp_ui->sideBar->show(); - } else { - readingListToggled(readingList->isChecked()); + } else if (readingList->isChecked()) { + readingListToggled(true); + } else if (tableOfContent->isChecked()) { + tableOfContentToggled(true); + } else { + mp_ui->sideBar->hide(); } } @@ -203,3 +231,8 @@ TopWidget *MainWindow::getTopWidget() { return mp_ui->mainToolBar; } + +TableOfContentBar *MainWindow::getTableOfContentBar() +{ + return mp_ui->tableofcontentbar; +} diff --git a/src/mainwindow.h b/src/mainwindow.h index a97bef15..4e580d1a 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -14,6 +14,8 @@ namespace Ui { class MainWindow; } +class TableOfContentBar; + class MainWindow : public QMainWindow { Q_OBJECT @@ -25,6 +27,7 @@ class MainWindow : public QMainWindow TabBar* getTabBar(); TopWidget* getTopWidget(); QWidget getMainView(); + TableOfContentBar *getTableOfContentBar(); protected: bool eventFilter(QObject* object, QEvent* event) override; @@ -35,6 +38,7 @@ private slots: void toggleFullScreen(); void tabChanged(TabBar::TabType); void readingListToggled(bool state); + void tableOfContentToggled(bool state); void hideTabAndTop(); void showTabAndTop(); void updateTabButtons(); diff --git a/src/tableofcontentbar.cpp b/src/tableofcontentbar.cpp new file mode 100644 index 00000000..f3252dc8 --- /dev/null +++ b/src/tableofcontentbar.cpp @@ -0,0 +1,72 @@ +#include "tableofcontentbar.h" +#include "ui_tableofcontentbar.h" +#include "kiwixapp.h" +#include +#include + +TableOfContentBar::TableOfContentBar(QWidget *parent) : + QFrame(parent), + ui(new Ui::tableofcontentbar) +{ + ui->setupUi(this); + ui->titleLabel->setFont(QFont("Selawik", 18, QFont::Weight::Medium)); + ui->titleLabel->setText(gt("table-of-content")); + ui->hideLabel->setFont(QFont("Selawik", 12)); + ui->hideLabel->setTextFormat(Qt::RichText); + + /* href is needed to make hide clickable, but not used. So Kiwix it is :) */ + ui->hideLabel->setText("" + gt("hide") + ""); + connect(ui->hideLabel, &QLabel::linkActivated, this, [=](){ + KiwixApp::instance()->getAction(KiwixApp::ToggleTOCAction)->setChecked(false); + }); + + ui->tree->setRootIsDecorated(false); + connect(ui->tree, &QTreeWidget::itemActivated, this, [=](QTreeWidgetItem* item) { + emit navigationRequested(item->data(0, Qt::UserRole).toString()); + }); +} + +TableOfContentBar::~TableOfContentBar() +{ + delete ui; +} + +namespace +{ + +void populateItem(const QJsonArray& headerArray, QTreeWidgetItem* parent) +{ + for (int i = 0; i < headerArray.size(); i++) + { + const auto header = headerArray[i].toObject(); + const auto item = new QTreeWidgetItem(parent); + item->setExpanded(true); + + auto numberList = parent->data(0, Qt::UserRole + 1).toStringList(); + numberList.append(QString::number(i + 1)); + item->setData(0, Qt::UserRole + 1, numberList); + + const auto itemNum = numberList.join("."); + const auto display = itemNum + " " + header["text"].toString(); + item->setData(0, Qt::DisplayRole, display); + item->setData(0, Qt::FontRole, QFont("Selawik", 12)); + item->setData(0, Qt::UserRole, header["anchor"].toString()); + item->setToolTip(0, display); + populateItem(header["child"].toArray(), item); + } +} + +} + +void TableOfContentBar::setupTree(const QJsonDocument& table) +{ + const auto tableUrl = table["url"].toString(); + const auto webView = KiwixApp::instance()->getTabWidget()->currentWebView(); + const auto currentUrl = webView->url().url(QUrl::RemoveFragment); + if (tableUrl != currentUrl) + return; + + ui->tree->clear(); + ui->tree->invisibleRootItem()->setData(0, Qt::UserRole + 1, QStringList{}); + populateItem(table["table"].toArray(), ui->tree->invisibleRootItem()); +} diff --git a/src/tableofcontentbar.h b/src/tableofcontentbar.h new file mode 100644 index 00000000..0566ad7f --- /dev/null +++ b/src/tableofcontentbar.h @@ -0,0 +1,28 @@ +#ifndef TABLEOFCONTENTBAR_H +#define TABLEOFCONTENTBAR_H + +#include + +namespace Ui { +class tableofcontentbar; +} + +class TableOfContentBar : public QFrame +{ + Q_OBJECT + +public: + explicit TableOfContentBar(QWidget *parent = nullptr); + ~TableOfContentBar(); + +public slots: + void setupTree(const QJsonDocument& table); + +signals: + void navigationRequested(const QUrl& anchor); + +private: + Ui::tableofcontentbar *ui; +}; + +#endif // TABLEOFCONTENTBAR_H diff --git a/src/tableofcontentbar.ui b/src/tableofcontentbar.ui new file mode 100644 index 00000000..8ebf8750 --- /dev/null +++ b/src/tableofcontentbar.ui @@ -0,0 +1,130 @@ + + + tableofcontentbar + + + + 0 + 0 + 400 + 300 + + + + + 0 + 0 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 16 + + + + + + + 0 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 0 + + + + + + + + + Qt::Horizontal + + + + + + + QFrame::NoFrame + + + 0 + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + Qt::ElideRight + + + 30 + + + true + + + true + + + true + + + false + + + + 1 + + + + + + + + + diff --git a/src/topwidget.cpp b/src/topwidget.cpp index c29d5927..9843b0bf 100644 --- a/src/topwidget.cpp +++ b/src/topwidget.cpp @@ -30,6 +30,9 @@ TopWidget::TopWidget(QWidget *parent) : QAction *random = app->getAction(KiwixApp::RandomArticleAction); addAction(random); + QAction *toc = app->getAction(KiwixApp::ToggleTOCAction); + addAction(toc); + // For CSS if (QGuiApplication::isLeftToRight()) { widgetForAction(back)->setObjectName("leftHistoryButton"); diff --git a/src/webview.cpp b/src/webview.cpp index 2004de7d..70420795 100644 --- a/src/webview.cpp +++ b/src/webview.cpp @@ -16,6 +16,10 @@ class QMenu; #include #include #include +#include +#include +#include "kiwixwebchannelobject.h" +#include "tableofcontentbar.h" zim::Entry getArchiveEntryFromUrl(const zim::Archive& archive, const QUrl& url); QString askForSaveFilePath(const QString& suggestedName); @@ -97,6 +101,19 @@ WebView::WebView(QWidget *parent) } }); #endif + + const auto channel = new QWebChannel(this); + 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::tableOfContentChanged, this, &WebView::onTableOfContentRecieved); + + const auto tocbar = KiwixApp::instance()->getMainWindow()->getTableOfContentBar(); + connect(this, &WebView::tableOfContentChanged, tocbar, &TableOfContentBar::setupTree); + connect(tocbar, &TableOfContentBar::navigationRequested, this, &WebView::onNavigationRequested); } WebView::~WebView() @@ -190,7 +207,48 @@ void WebView::saveViewContent() catch (...) { /* Blank */} } -void WebView::addHistoryItemAction(QMenu *menu, const QWebEngineHistoryItem &item, int n) const +void WebView::onCurrentTitleChanged() +{ + const auto tabbar = KiwixApp::instance()->getTabWidget(); + const auto tableObject = m_tableOfContent.object(); + const auto tableValid = tableObject["url"].toString() == url().url(); + + /* When table invalid, then we are loading and the emit will be + handled by KiwixWebChannelObject::tableOfContentChanged. + */ + if (tabbar->currentWebView() == this && tableValid) + emit tableOfContentChanged(m_tableOfContent); +} + +void WebView::onTableOfContentRecieved(const QString& tableJson) +{ + const auto tabbar = KiwixApp::instance()->getTabWidget(); + m_tableOfContent = QJsonDocument::fromJson(tableJson.toUtf8()); + + if (tabbar->currentWebView() == this) + emit tableOfContentChanged(m_tableOfContent); +} + +void WebView::onNavigationRequested(const QUrl &anchor) +{ + const auto tabbar = KiwixApp::instance()->getTabWidget(); + const auto currentUrl = url().url(); + const auto anchorNoHash = anchor.url(QUrl::RemoveFragment); + + if (tabbar->currentWebView() == this && anchorNoHash == currentUrl) + { + setUrl(anchor); + + /* We need to reset the url, otherwise repeated navigations + and url checks from the TableOfContentBar would fail. + */ + setUrl(currentUrl); + } +} + +void WebView::addHistoryItemAction(QMenu *menu, + const QWebEngineHistoryItem &item, + int n) const { QAction *a = menu->addAction(item.title()); a->setData(QVariant::fromValue(n)); diff --git a/src/webview.h b/src/webview.h index e2419946..8045fc82 100644 --- a/src/webview.h +++ b/src/webview.h @@ -5,6 +5,7 @@ #include #include #include +#include #include "findinpagebar.h" @@ -53,6 +54,7 @@ public slots: signals: void iconChanged(const QIcon& icon); void zimIdChanged(const QString& zimId); + void tableOfContentChanged(const QJsonDocument& table); protected: virtual QWebEngineView* createWindow(QWebEnginePage::WebWindowType type); @@ -67,12 +69,16 @@ public slots: private slots: void gotoTriggeredHistoryItemAction(); + void onCurrentTitleChanged(); + void onTableOfContentRecieved(const QString& tableJson); + void onNavigationRequested(const QUrl& anchor); private: void addHistoryItemAction(QMenu *menu, const QWebEngineHistoryItem &item, int n) const; void applyCorrectZoomFactor(); QMenu* createStandardContextMenu(); QMenu* createLinkContextMenu(); + QJsonDocument m_tableOfContent; }; #endif // WEBVIEW_H diff --git a/ui/mainwindow.ui b/ui/mainwindow.ui index dd2749e3..269b91bf 100644 --- a/ui/mainwindow.ui +++ b/ui/mainwindow.ui @@ -145,6 +145,7 @@ + @@ -201,6 +202,12 @@
src/readinglistbar.h
1 + + TableOfContentBar + QWidget +
src/tableofcontentbar.h
+ 1 +