/* This file is (c) 2008-2012 Konstantin Isakov * Part of GoldenDict. Licensed under GPLv3 or later, see the LICENSE file */ #include "articleview.hh" #include "dict/programs.hh" #include "folding.hh" #include "gddebug.hh" #include "gestures.hh" #include "globalbroadcaster.hh" #include "speechclient.hh" #include "utils.hh" #include "webmultimediadownload.hh" #include "wildcard.hh" #include "wstring_qt.hh" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #if ( QT_VERSION >= QT_VERSION_CHECK( 5, 0, 0 ) && QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) #include #endif #if ( QT_VERSION >= QT_VERSION_CHECK( 6, 0, 0 ) ) #include #include #include #include #endif #ifdef Q_OS_WIN32 #include #include #endif using std::map; using std::list; namespace { char const * const scrollToPrefix = "gdfrom-"; bool isScrollTo( QString const & id ) { return id.startsWith( scrollToPrefix ); } QString dictionaryIdFromScrollTo( QString const & scrollTo ) { Q_ASSERT( isScrollTo( scrollTo ) ); constexpr int scrollToPrefixLength = 7; return scrollTo.mid( scrollToPrefixLength ); } QString searchStatusMessageNoMatches() { return ArticleView::tr( "Phrase not found" ); } QString searchStatusMessage( int activeMatch, int matchCount ) { Q_ASSERT( matchCount > 0 ); Q_ASSERT( activeMatch > 0 ); Q_ASSERT( activeMatch <= matchCount ); return ArticleView::tr( "%1 of %2 matches" ).arg( activeMatch ).arg( matchCount ); } } // unnamed namespace QString ArticleView::scrollToFromDictionaryId( QString const & dictionaryId ) { Q_ASSERT( !isScrollTo( dictionaryId ) ); return scrollToPrefix + dictionaryId; } ArticleView::ArticleView( QWidget * parent, ArticleNetworkAccessManager & nm, AudioPlayerPtr const & audioPlayer_, std::vector< sptr< Dictionary::Class > > const & allDictionaries_, Instances::Groups const & groups_, bool popupView_, Config::Class const & cfg_, QLineEdit const * translateLine_, QAction * dictionaryBarToggled_, unsigned int currentGroupId_ ): QWidget( parent ), articleNetMgr( nm ), audioPlayer( audioPlayer_ ), allDictionaries( allDictionaries_ ), groups( groups_ ), popupView( popupView_ ), cfg( cfg_ ), pasteAction( this ), articleUpAction( this ), articleDownAction( this ), goBackAction( this ), goForwardAction( this ), selectCurrentArticleAction( this ), copyAsTextAction( this ), inspectAction( this ), searchIsOpened( false ), dictionaryBarToggled( dictionaryBarToggled_ ), currentGroupId( currentGroupId_ ), translateLine( translateLine_ ) { // setup GUI webview = new ArticleWebView( this ); ftsSearchPanel = new FtsSearchPanel( this ); searchPanel = new SearchPanel( this ); // Layout auto * mainLayout = new QVBoxLayout( this ); mainLayout->addWidget( webview ); mainLayout->addWidget( ftsSearchPanel ); mainLayout->addWidget( searchPanel ); webview->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); ftsSearchPanel->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Minimum ); searchPanel->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Minimum ); mainLayout->setContentsMargins( 0, 0, 0, 0 ); // end UI setup connect( searchPanel->previous, &QPushButton::clicked, this, &ArticleView::on_searchPrevious_clicked ); connect( searchPanel->next, &QPushButton::clicked, this, &ArticleView::on_searchNext_clicked ); connect( searchPanel->close, &QPushButton::clicked, this, &ArticleView::on_searchCloseButton_clicked ); connect( searchPanel->caseSensitive, &QPushButton::clicked, this, &ArticleView::on_searchCaseSensitive_clicked ); connect( searchPanel->lineEdit, &QLineEdit::textEdited, this, &ArticleView::on_searchText_textEdited ); connect( searchPanel->lineEdit, &QLineEdit::returnPressed, this, &ArticleView::on_searchText_returnPressed ); connect( ftsSearchPanel->next, &QPushButton::clicked, this, &ArticleView::on_ftsSearchNext_clicked ); connect( ftsSearchPanel->previous, &QPushButton::clicked, this, &ArticleView::on_ftsSearchPrevious_clicked ); // webview->setUp( const_cast< Config::Class * >( &cfg ) ); syncBackgroundColorWithCfgDarkReader(); goBackAction.setShortcut( QKeySequence( "Alt+Left" ) ); webview->addAction( &goBackAction ); connect( &goBackAction, &QAction::triggered, this, &ArticleView::back ); goForwardAction.setShortcut( QKeySequence( "Alt+Right" ) ); webview->addAction( &goForwardAction ); connect( &goForwardAction, &QAction::triggered, this, &ArticleView::forward ); webview->pageAction( QWebEnginePage::Copy )->setShortcut( QKeySequence::Copy ); webview->addAction( webview->pageAction( QWebEnginePage::Copy ) ); QAction * selectAll = webview->pageAction( QWebEnginePage::SelectAll ); selectAll->setShortcut( QKeySequence::SelectAll ); selectAll->setShortcutContext( Qt::WidgetWithChildrenShortcut ); webview->addAction( selectAll ); webview->setContextMenuPolicy( Qt::CustomContextMenu ); connect( webview, &QWebEngineView::loadFinished, this, &ArticleView::loadFinished ); connect( webview, &ArticleWebView::linkClicked, this, &ArticleView::linkClicked ); connect( webview->page(), &QWebEnginePage::titleChanged, this, &ArticleView::handleTitleChanged ); connect( webview, &QWidget::customContextMenuRequested, this, &ArticleView::contextMenuRequested ); connect( webview->page(), &QWebEnginePage::linkHovered, this, &ArticleView::linkHovered ); connect( webview, &ArticleWebView::doubleClicked, this, &ArticleView::doubleClicked ); pasteAction.setShortcut( QKeySequence::Paste ); webview->addAction( &pasteAction ); connect( &pasteAction, &QAction::triggered, this, &ArticleView::pasteTriggered ); articleUpAction.setShortcut( QKeySequence( "Alt+Up" ) ); webview->addAction( &articleUpAction ); connect( &articleUpAction, &QAction::triggered, this, &ArticleView::moveOneArticleUp ); articleDownAction.setShortcut( QKeySequence( "Alt+Down" ) ); webview->addAction( &articleDownAction ); connect( &articleDownAction, &QAction::triggered, this, &ArticleView::moveOneArticleDown ); selectCurrentArticleAction.setShortcut( QKeySequence( "Ctrl+Shift+A" ) ); selectCurrentArticleAction.setText( tr( "Select Current Article" ) ); webview->addAction( &selectCurrentArticleAction ); connect( &selectCurrentArticleAction, &QAction::triggered, this, &ArticleView::selectCurrentArticle ); copyAsTextAction.setShortcut( QKeySequence( "Ctrl+Shift+C" ) ); copyAsTextAction.setText( tr( "Copy as text" ) ); webview->addAction( ©AsTextAction ); connect( ©AsTextAction, &QAction::triggered, this, &ArticleView::copyAsText ); inspectAction.setShortcut( QKeySequence( Qt::Key_F12 ) ); inspectAction.setText( tr( "Inspect" ) ); webview->addAction( &inspectAction ); connect( &inspectAction, &QAction::triggered, this, &ArticleView::inspectElement ); webview->installEventFilter( this ); searchPanel->installEventFilter( this ); ftsSearchPanel->installEventFilter( this ); QWebEngineSettings * settings = webview->settings(); settings->setUnknownUrlSchemePolicy( QWebEngineSettings::UnknownUrlSchemePolicy::DisallowUnknownUrlSchemes ); #if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) settings->defaultSettings()->setAttribute( QWebEngineSettings::LocalContentCanAccessRemoteUrls, true ); settings->defaultSettings()->setAttribute( QWebEngineSettings::LocalContentCanAccessFileUrls, true ); settings->defaultSettings()->setAttribute( QWebEngineSettings::ErrorPageEnabled, false ); settings->defaultSettings()->setAttribute( QWebEngineSettings::PluginsEnabled, true ); settings->defaultSettings()->setAttribute( QWebEngineSettings::PlaybackRequiresUserGesture, false ); settings->defaultSettings()->setAttribute( QWebEngineSettings::JavascriptCanAccessClipboard, true ); settings->defaultSettings()->setAttribute( QWebEngineSettings::PrintElementBackgrounds, false ); #else settings->setAttribute( QWebEngineSettings::LocalContentCanAccessRemoteUrls, true ); settings->setAttribute( QWebEngineSettings::LocalContentCanAccessFileUrls, true ); settings->setAttribute( QWebEngineSettings::ErrorPageEnabled, false ); settings->setAttribute( QWebEngineSettings::PluginsEnabled, true ); settings->setAttribute( QWebEngineSettings::PlaybackRequiresUserGesture, false ); settings->setAttribute( QWebEngineSettings::JavascriptCanAccessClipboard, true ); settings->setAttribute( QWebEngineSettings::PrintElementBackgrounds, false ); #endif auto html = articleNetMgr.getHtml( ResourceType::UNTITLE ); webview->setHtml( QString::fromStdString( html ) ); expandOptionalParts = cfg.preferences.alwaysExpandOptionalParts; #ifndef Q_OS_MACOS webview->grabGesture( Gestures::GDPinchGestureType ); webview->grabGesture( Gestures::GDSwipeGestureType ); #endif connect( GlobalBroadcaster::instance(), &GlobalBroadcaster::dictionaryChanges, this, &ArticleView::setActiveDictIds ); connect( GlobalBroadcaster::instance(), &GlobalBroadcaster::dictionaryClear, this, &ArticleView::dictionaryClear ); channel = new QWebChannel( webview->page() ); agent = new ArticleViewAgent( this ); attachWebChannelToHtml(); ankiConnector = new AnkiConnector( this, cfg ); connect( ankiConnector, &AnkiConnector::errorText, this, [ this ]( QString const & errorText ) { emit statusBarMessage( errorText ); } ); // Set up an Anki action if Anki integration is enabled in settings. if ( cfg.preferences.ankiConnectServer.enabled ) { sendToAnkiAction.setShortcut( QKeySequence( "Ctrl+Shift+N" ) ); webview->addAction( &sendToAnkiAction ); connect( &sendToAnkiAction, &QAction::triggered, this, &ArticleView::handleAnkiAction ); } } // explicitly report the minimum size, to avoid // sidebar widgets' improper resize during restore QSize ArticleView::minimumSizeHint() const { return searchPanel->minimumSizeHint(); } void ArticleView::setCurrentGroupId( unsigned currentGrgId ) { currentGroupId = currentGrgId; } unsigned ArticleView::getCurrentGroupId() { return currentGroupId; } ArticleView::~ArticleView() { cleanupTemp(); audioPlayer->stop(); //channel->deregisterObject(this); #ifndef Q_OS_MACOS webview->ungrabGesture( Gestures::GDPinchGestureType ); webview->ungrabGesture( Gestures::GDSwipeGestureType ); #endif } void ArticleView::showDefinition( QString const & word, unsigned group, QString const & scrollTo, Contexts const & contexts_ ) { GlobalBroadcaster::instance()->pronounce_engine.reset(); currentWord = word.trimmed(); if ( currentWord.isEmpty() ) return; historyMode = false; currentActiveDictIds.clear(); // first, let's stop the player audioPlayer->stop(); QUrl req; Contexts contexts( contexts_ ); req.setScheme( "gdlookup" ); req.setHost( "localhost" ); Utils::Url::addQueryItem( req, "word", word ); Utils::Url::addQueryItem( req, "group", QString::number( group ) ); if ( cfg.preferences.ignoreDiacritics ) Utils::Url::addQueryItem( req, "ignore_diacritics", "1" ); if ( scrollTo.size() ) Utils::Url::addQueryItem( req, "scrollto", scrollTo ); if ( delayedHighlightText.size() ) { Utils::Url::addQueryItem( req, "regexp", delayedHighlightText ); delayedHighlightText.clear(); } if ( Contexts::Iterator pos = contexts.find( "gdanchor" ); pos != contexts.end() ) { Utils::Url::addQueryItem( req, "gdanchor", contexts[ "gdanchor" ] ); contexts.erase( pos ); } if ( contexts.size() ) { QBuffer buf; buf.open( QIODevice::WriteOnly ); QDataStream stream( &buf ); stream << contexts; buf.close(); Utils::Url::addQueryItem( req, "contexts", QString::fromLatin1( buf.buffer().toBase64() ) ); } QString mutedDicts = getMutedForGroup( group ); if ( mutedDicts.size() ) Utils::Url::addQueryItem( req, "muted", mutedDicts ); // Any search opened is probably irrelevant now closeSearch(); //QApplication::setOverrideCursor( Qt::WaitCursor ); webview->setCursor( Qt::WaitCursor ); load( req ); // Update headwords history emit sendWordToHistory( word ); } void ArticleView::showDefinition( QString const & word, QStringList const & dictIDs, QRegExp const & searchRegExp, unsigned group, bool ignoreDiacritics ) { if ( dictIDs.isEmpty() ) return; currentWord = word.trimmed(); if ( currentWord.isEmpty() ) return; historyMode = false; //clear founded dicts. currentActiveDictIds.clear(); // first, let's stop the player audioPlayer->stop(); QUrl req; req.setScheme( "gdlookup" ); req.setHost( "localhost" ); Utils::Url::addQueryItem( req, "word", word ); Utils::Url::addQueryItem( req, "dictionaries", dictIDs.join( "," ) ); Utils::Url::addQueryItem( req, "regexp", searchRegExp.pattern() ); if ( searchRegExp.caseSensitivity() == Qt::CaseSensitive ) Utils::Url::addQueryItem( req, "matchcase", "1" ); if ( searchRegExp.patternSyntax() == QRegExp::WildcardUnix ) Utils::Url::addQueryItem( req, "wildcards", "1" ); Utils::Url::addQueryItem( req, "group", QString::number( group ) ); if ( ignoreDiacritics ) Utils::Url::addQueryItem( req, "ignore_diacritics", "1" ); // Any search opened is probably irrelevant now closeSearch(); webview->setCursor( Qt::WaitCursor ); load( req ); // Update headwords history emit sendWordToHistory( word ); } void ArticleView::sendToAnki( QString const & word, QString const & dict_definition, QString const & sentence ) { ankiConnector->sendToAnki( word, dict_definition, sentence ); } void ArticleView::showAnticipation() { webview->setHtml( "" ); webview->setCursor( Qt::WaitCursor ); } void ArticleView::inspectElement() { emit inspectSignal( webview->page() ); } void ArticleView::loadFinished( bool result ) { setZoomFactor( cfg.preferences.zoomFactor ); QUrl url = webview->url(); qDebug() << "article view loaded url:" << url.url().left( 200 ) << result; if ( url.url() == "about:blank" ) { return; } if ( !result ) { qWarning() << "article loaded unsuccessful"; return; } if ( cfg.preferences.autoScrollToTargetArticle ) { QString const scrollTo = Utils::Url::queryItemValue( url, "scrollto" ); if ( isScrollTo( scrollTo ) ) { setCurrentArticle( scrollTo, true ); } else { setActiveArticleId( "" ); } } else { //clear current active dictionary id; setActiveArticleId( "" ); } webview->unsetCursor(); // Expand collapsed article if only one loaded webview->page()->runJavaScript( QString( "gdCheckArticlesNumber();" ) ); if ( !Utils::Url::queryItemValue( url, "gdanchor" ).isEmpty() ) { const QString anchor = QUrl::fromPercentEncoding( Utils::Url::encodedQueryItemValue( url, "gdanchor" ) ); // Find GD anchor on page url.clear(); url.setFragment( anchor ); webview->page()->runJavaScript( QString( "window.location.hash = \"%1\"" ).arg( QString::fromUtf8( url.toEncoded() ) ) ); } //the click audio url such as gdau://xxxx ,webview also emit a pageLoaded signal but with the result is false.need future investigation. //the audio link click ,no need to emit pageLoaded signal if ( result ) { emit pageLoaded( this ); } if ( Utils::Url::hasQueryItem( webview->url(), "regexp" ) ) { highlightFTSResults(); } } void ArticleView::handleTitleChanged( QString const & title ) { if ( !title.isEmpty() ) // Qt 5.x WebKit raise signal titleChanges(QString()) while navigation within page emit titleChanged( this, title ); } unsigned ArticleView::getGroup( QUrl const & url ) { if ( url.scheme() == "gdlookup" && Utils::Url::hasQueryItem( url, "group" ) ) return Utils::Url::queryItemValue( url, "group" ).toUInt(); return 0; } QStringList ArticleView::getArticlesList() { return currentActiveDictIds; } QString ArticleView::getActiveArticleId() { return activeDictId; } void ArticleView::setActiveArticleId( QString const & dictId ) { this->activeDictId = dictId; } QString ArticleView::getCurrentArticle() { const QString dictId = getActiveArticleId(); return scrollToFromDictionaryId( dictId ); } void ArticleView::jumpToDictionary( QString const & id, bool force ) { // jump only if neceessary, or when forced if ( const QString targetArticle = scrollToFromDictionaryId( id ); force && targetArticle != getCurrentArticle() ) { setCurrentArticle( targetArticle, true ); } } bool ArticleView::setCurrentArticle( QString const & id, bool moveToIt ) { if ( !isScrollTo( id ) ) return false; // Incorrect id if ( !webview->isVisible() ) return false; // No action on background page, scrollIntoView there don't work if ( moveToIt ) { QString dictId = id.mid( 7 ); if ( dictId.isEmpty() ) return false; QString script = QString( "var elem=document.getElementById('%1'); " "if(elem!=undefined){elem.scrollIntoView(true);} if(typeof gdMakeArticleActive !='undefined') gdMakeArticleActive('%2',true);" ) .arg( id, dictId ); onJsActiveArticleChanged( id ); webview->page()->runJavaScript( script ); setActiveArticleId( dictId ); } return true; } void ArticleView::selectCurrentArticle() { webview->page()->runJavaScript( QString( "gdSelectArticle( '%1' );var elem=document.getElementById('%2'); if(elem!=undefined){elem.scrollIntoView(true);}" ) .arg( getActiveArticleId(), getCurrentArticle() ) ); } void ArticleView::isFramedArticle( QString const & ca, const std::function< void( bool ) > & callback ) { if ( ca.isEmpty() ) callback( false ); webview->page()->runJavaScript( QString( "!!document.getElementById('gdexpandframe-%1');" ).arg( ca.mid( 7 ) ), [ callback ]( const QVariant & res ) { callback( res.toBool() ); } ); } void ArticleView::tryMangleWebsiteClickedUrl( QUrl & url, Contexts & contexts ) { // Don't try mangling audio urls, even if they are from the framed websites if ( !url.isValid() ) return; if ( ( url.scheme() == "http" || url.scheme() == "https" ) && !Utils::Url::isWebAudioUrl( url ) ) { // Maybe a link inside a website was clicked? QString ca = getCurrentArticle(); isFramedArticle( ca, []( bool framed ) {} ); } } void ArticleView::load( QUrl const & url ) { webview->load( url ); } void ArticleView::cleanupTemp() { auto it = desktopOpenedTempFiles.begin(); while ( it != desktopOpenedTempFiles.end() ) { if ( QFile::remove( *it ) ) it = desktopOpenedTempFiles.erase( it ); else ++it; } } bool ArticleView::handleF3( QObject * /*obj*/, QEvent * ev ) { if ( ev->type() == QEvent::ShortcutOverride || ev->type() == QEvent::KeyPress ) { QKeyEvent * ke = static_cast< QKeyEvent * >( ev ); if ( ke->key() == Qt::Key_F3 && isSearchOpened() ) { if ( !ke->modifiers() ) { if ( ev->type() == QEvent::KeyPress ) on_searchNext_clicked(); ev->accept(); return true; } if ( ke->modifiers() == Qt::ShiftModifier ) { if ( ev->type() == QEvent::KeyPress ) on_searchPrevious_clicked(); ev->accept(); return true; } } if ( ke->key() == Qt::Key_F3 && ftsSearchIsOpened ) { if ( !ke->modifiers() ) { if ( ev->type() == QEvent::KeyPress ) on_ftsSearchNext_clicked(); ev->accept(); return true; } if ( ke->modifiers() == Qt::ShiftModifier ) { if ( ev->type() == QEvent::KeyPress ) on_ftsSearchPrevious_clicked(); ev->accept(); return true; } } } return false; } bool ArticleView::eventFilter( QObject * obj, QEvent * ev ) { #ifdef Q_OS_MAC if ( ev->type() == QEvent::NativeGesture ) { qDebug() << "it's a Native Gesture!"; // handle Qt::ZoomNativeGesture Qt::SmartZoomNativeGesture here // ignore swipe left/right. // QWebEngine can handle Qt::SmartZoomNativeGesture. } #else if ( ev->type() == QEvent::Gesture ) { Gestures::GestureResult result; QPoint pt; bool handled = Gestures::handleGestureEvent( obj, ev, result, pt ); if ( handled ) { if ( result == Gestures::ZOOM_IN ) emit zoomIn(); else if ( result == Gestures::ZOOM_OUT ) emit zoomOut(); else if ( result == Gestures::SWIPE_LEFT ) back(); else if ( result == Gestures::SWIPE_RIGHT ) forward(); else if ( result == Gestures::SWIPE_UP || result == Gestures::SWIPE_DOWN ) { int delta = result == Gestures::SWIPE_UP ? -120 : 120; QWidget * widget = static_cast< QWidget * >( obj ); QPoint angleDelta( 0, delta ); QPoint pixelDetal; QWidget * child = widget->childAt( widget->mapFromGlobal( pt ) ); if ( child ) { QWheelEvent whev( child->mapFromGlobal( pt ), pt, pixelDetal, angleDelta, Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false ); qApp->sendEvent( child, &whev ); } else { QWheelEvent whev( widget->mapFromGlobal( pt ), pt, pixelDetal, angleDelta, Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false ); qApp->sendEvent( widget, &whev ); } } } return handled; } if ( ev->type() == QEvent::MouseMove ) { if ( Gestures::isFewTouchPointsPresented() ) { ev->accept(); return true; } } #endif if ( handleF3( obj, ev ) ) { return true; } if ( obj == webview ) { if ( ev->type() == QEvent::KeyPress ) { auto keyEvent = static_cast< QKeyEvent * >( ev ); if ( keyEvent->modifiers() & ( Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier ) ) return false; // A non-typing modifier is pressed if ( Utils::ignoreKeyEvent( keyEvent ) || keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter ) return false; // Those key have other uses than to start typing QString text = keyEvent->text(); if ( text.size() ) { emit typingEvent( text ); return true; } } else if ( ev->type() == QEvent::Wheel ) { QWheelEvent * pe = static_cast< QWheelEvent * >( ev ); if ( pe->modifiers().testFlag( Qt::ControlModifier ) ) { if ( pe->angleDelta().y() > 0 ) { zoomIn(); } else { zoomOut(); } } } } else return QWidget::eventFilter( obj, ev ); return false; } QString ArticleView::getMutedForGroup( unsigned group ) { if ( dictionaryBarToggled && dictionaryBarToggled->isChecked() ) { // Dictionary bar is active -- mute the muted dictionaries Instances::Group const * groupInstance = groups.findGroup( group ); // Find muted dictionaries for current group Config::Group const * grp = cfg.getGroup( group ); Config::MutedDictionaries const * mutedDictionaries; if ( group == Instances::Group::AllGroupId ) mutedDictionaries = popupView ? &cfg.popupMutedDictionaries : &cfg.mutedDictionaries; else mutedDictionaries = grp ? ( popupView ? &grp->popupMutedDictionaries : &grp->mutedDictionaries ) : nullptr; if ( !mutedDictionaries ) return {}; QStringList mutedDicts; if ( groupInstance ) { for ( const auto & dictionarie : groupInstance->dictionaries ) { QString id = QString::fromStdString( dictionarie->getId() ); if ( mutedDictionaries->contains( id ) ) mutedDicts.append( id ); } } if ( !mutedDicts.empty() ) return mutedDicts.join( "," ); } return {}; } QStringList ArticleView::getMutedDictionaries( unsigned group ) { if ( dictionaryBarToggled && dictionaryBarToggled->isChecked() ) { // Dictionary bar is active -- mute the muted dictionaries Instances::Group const * groupInstance = groups.findGroup( group ); // Find muted dictionaries for current group Config::Group const * grp = cfg.getGroup( group ); Config::MutedDictionaries const * mutedDictionaries; if ( group == Instances::Group::AllGroupId ) mutedDictionaries = popupView ? &cfg.popupMutedDictionaries : &cfg.mutedDictionaries; else mutedDictionaries = grp ? ( popupView ? &grp->popupMutedDictionaries : &grp->mutedDictionaries ) : nullptr; if ( !mutedDictionaries ) return {}; QStringList mutedDicts; if ( groupInstance ) { for ( const auto & dictionarie : groupInstance->dictionaries ) { QString id = QString::fromStdString( dictionarie->getId() ); if ( mutedDictionaries->contains( id ) ) mutedDicts.append( id ); } } return mutedDicts; } return {}; } void ArticleView::linkHovered( const QString & link ) { QString msg; QUrl url( link ); if ( url.scheme() == "bres" ) { msg = tr( "Resource" ); } else if ( url.scheme() == "gdau" || Utils::Url::isAudioUrl( url ) ) { msg = tr( "Audio" ); } else if ( url.scheme() == "gdtts" ) { msg = tr( "TTS Voice" ); } else if ( url.scheme() == "gdvideo" ) { if ( url.path().isEmpty() ) { msg = tr( "Video" ); } else { QString path = url.path(); if ( path.startsWith( '/' ) ) { path = path.mid( 1 ); } msg = tr( "Video: %1" ).arg( path ); } } else if ( url.scheme() == "gdlookup" || url.scheme().compare( "bword" ) == 0 ) { QString def = url.path(); if ( def.startsWith( "/" ) ) { def = def.mid( 1 ); } if ( Utils::Url::hasQueryItem( url, "dict" ) ) { // Link to other dictionary QString dictName( Utils::Url::queryItemValue( url, "dict" ) ); if ( !dictName.isEmpty() ) msg = tr( "Definition from dictionary \"%1\": %2" ).arg( dictName, def ); } if ( msg.isEmpty() ) { if ( def.isEmpty() && url.hasFragment() ) msg = '#' + url.fragment(); // this must be a citation, footnote or backlink else msg = tr( "Definition: %1" ).arg( def ); } } else { msg = link; } emit statusBarMessage( msg ); } void ArticleView::attachWebChannelToHtml() { // set the web channel to be used by the page // see http://doc.qt.io/qt-5/qwebenginepage.html#setWebChannel webview->page()->setWebChannel( channel, QWebEngineScript::MainWorld ); // register QObjects to be exposed to JavaScript channel->registerObject( QStringLiteral( "articleview" ), agent ); } void ArticleView::linkClicked( QUrl const & url_ ) { Qt::KeyboardModifiers kmod = QApplication::keyboardModifiers(); // Lock jump on links while Alt key is pressed if ( kmod & Qt::AltModifier ) return; QUrl url( url_ ); Contexts contexts; tryMangleWebsiteClickedUrl( url, contexts ); if ( !popupView && ( webview->isMidButtonPressed() || ( kmod & ( Qt::ControlModifier | Qt::ShiftModifier ) ) ) && !isAudioLink( url ) ) { // Mid button or Control/Shift is currently pressed - open the link in new tab webview->resetMidButtonPressed(); emit openLinkInNewTab( url, webview->url(), getCurrentArticle(), contexts ); } else openLink( url, webview->url(), getCurrentArticle(), contexts ); } void ArticleView::linkClickedInHtml( QUrl const & url_ ) { webview->linkClickedInHtml( url_ ); if ( !url_.isEmpty() ) { linkClicked( url_ ); } } void ArticleView::makeAnkiCardFromArticle( QString const & article_id ) { auto const js_code = QString( R"EOF(document.getElementById("gdarticlefrom-%1").innerText)EOF" ).arg( article_id ); webview->page()->runJavaScript( js_code, [ this ]( const QVariant & article_text ) { sendToAnki( webview->title(), article_text.toString(), translateLine->text() ); } ); } void ArticleView::openLink( QUrl const & url, QUrl const & ref, QString const & scrollTo, Contexts const & contexts_ ) { audioPlayer->stop(); qDebug() << "open link url:" << url; auto [ valid, word ] = Utils::Url::getQueryWord( url ); if ( valid && word.isEmpty() ) { //if valid=true and word is empty,the url must be a invalid gdlookup url. //else if valid=false,the url should be external urls. return; } Contexts contexts( contexts_ ); if ( url.scheme().compare( "ankisearch" ) == 0 ) { ankiConnector->ankiSearch( url.path() ); return; } else if ( url.scheme().compare( "ankicard" ) == 0 ) { // If article id is set in path and selection is empty, use text from the current article. // Otherwise, grab currently selected text and use it as the definition. if ( !url.path().isEmpty() && webview->selectedText().isEmpty() ) { makeAnkiCardFromArticle( url.path() ); } else { sendToAnki( webview->title(), webview->selectedText(), translateLine->text() ); } qDebug() << "requested to make Anki card."; return; } else if ( url.scheme().compare( "bword" ) == 0 || url.scheme().compare( "entry" ) == 0 ) { if ( Utils::Url::hasQueryItem( ref, "dictionaries" ) ) { QStringList dictsList = Utils::Url::queryItemValue( ref, "dictionaries" ).split( ",", Qt::SkipEmptyParts ); showDefinition( word, dictsList, QRegExp(), getGroup( ref ), false ); } else showDefinition( word, getGroup( ref ), scrollTo, contexts ); } else if ( url.scheme() == "gdlookup" ) // Plain html links inherit gdlookup scheme { if ( url.hasFragment() ) { webview->page()->runJavaScript( QString( "window.location = \"%1\"" ).arg( QString::fromUtf8( url.toEncoded() ) ) ); } else { if ( Utils::Url::hasQueryItem( ref, "dictionaries" ) ) { // Specific dictionary group from full-text search QStringList dictsList = Utils::Url::queryItemValue( ref, "dictionaries" ).split( ",", Qt::SkipEmptyParts ); showDefinition( url.path().mid( 1 ), dictsList, QRegExp(), getGroup( ref ), false ); return; } if ( Utils::Url::hasQueryItem( url, "dictionaries" ) ) { // Specific dictionary group from full-text search QStringList dictsList = Utils::Url::queryItemValue( url, "dictionaries" ).split( ",", Qt::SkipEmptyParts ); showDefinition( word, dictsList, QRegExp(), getGroup( url ), false ); return; } QString newScrollTo( scrollTo ); if ( Utils::Url::hasQueryItem( url, "dict" ) ) { // Link to other dictionary QString dictName( Utils::Url::queryItemValue( url, "dict" ) ); for ( const auto & allDictionarie : allDictionaries ) { if ( dictName.compare( QString::fromUtf8( allDictionarie->getName().c_str() ) ) == 0 ) { newScrollTo = scrollToFromDictionaryId( QString::fromUtf8( allDictionarie->getId().c_str() ) ); break; } } } if ( Utils::Url::hasQueryItem( url, "gdanchor" ) ) contexts[ "gdanchor" ] = Utils::Url::queryItemValue( url, "gdanchor" ); showDefinition( word, getGroup( ref ), newScrollTo, contexts ); } } else if ( url.scheme() == "bres" || url.scheme() == "gdau" || url.scheme() == "gdvideo" || Utils::Url::isAudioUrl( url ) ) { // Download it // Clear any pending ones resourceDownloadRequests.clear(); resourceDownloadUrl = url; if ( Utils::Url::isWebAudioUrl( url ) ) { sptr< Dictionary::DataRequest > req = std::make_shared< Dictionary::WebMultimediaDownload >( url, articleNetMgr ); resourceDownloadRequests.push_back( req ); connect( req.get(), &Dictionary::Request::finished, this, &ArticleView::resourceDownloadFinished ); } else if ( url.scheme() == "gdau" && url.host() == "search" ) { // Since searches should be limited to current group, we just do them // here ourselves since otherwise we'd need to pass group id to netmgr // and it should've been having knowledge of the current groups, too. unsigned currentGroup = getGroup( ref ); std::vector< sptr< Dictionary::Class > > const * activeDicts = nullptr; if ( !groups.empty() ) { for ( const auto & group : groups ) if ( group.id == currentGroup ) { activeDicts = &( group.dictionaries ); break; } } else activeDicts = &allDictionaries; if ( activeDicts ) { unsigned preferred = UINT_MAX; if ( url.hasFragment() ) { // Find sound in the preferred dictionary QString preferredName = Utils::Url::fragment( url ); try { for ( unsigned x = 0; x < activeDicts->size(); ++x ) { if ( preferredName.compare( QString::fromUtf8( ( *activeDicts )[ x ]->getName().c_str() ) ) == 0 ) { preferred = x; sptr< Dictionary::DataRequest > req = ( *activeDicts )[ x ]->getResource( url.path().mid( 1 ).toUtf8().data() ); resourceDownloadRequests.push_back( req ); if ( !req->isFinished() ) { // Queued loading connect( req.get(), &Dictionary::Request::finished, this, &ArticleView::resourceDownloadFinished ); } else { // Immediate loading if ( req->dataSize() > 0 ) { // Resource already found, stop next search resourceDownloadFinished(); return; } } break; } } } catch ( std::exception & e ) { emit statusBarMessage( tr( "ERROR: %1" ).arg( e.what() ), 10000, QPixmap( ":/icons/error.svg" ) ); } } for ( unsigned x = 0; x < activeDicts->size(); ++x ) { try { if ( x == preferred ) continue; sptr< Dictionary::DataRequest > req = ( *activeDicts )[ x ]->getResource( url.path().mid( 1 ).toUtf8().data() ); resourceDownloadRequests.push_back( req ); if ( !req->isFinished() ) { // Queued loading connect( req.get(), &Dictionary::Request::finished, this, &ArticleView::resourceDownloadFinished ); } else { // Immediate loading if ( req->dataSize() > 0 ) { // Resource already found, stop next search break; } } } catch ( std::exception & e ) { emit statusBarMessage( tr( "ERROR: %1" ).arg( e.what() ), 10000, QPixmap( ":/icons/error.svg" ) ); } } } } else { // Normal resource download QString contentType; sptr< Dictionary::DataRequest > req = articleNetMgr.getResource( url, contentType ); if ( !req.get() ) { qDebug() << "request failed: " << url; // Request failed, fail } else if ( req->isFinished() && req->dataSize() >= 0 ) { // Have data ready, handle it resourceDownloadRequests.push_back( req ); resourceDownloadFinished(); return; } else if ( !req->isFinished() ) { // Queue to be handled when done resourceDownloadRequests.push_back( req ); connect( req.get(), &Dictionary::Request::finished, this, &ArticleView::resourceDownloadFinished ); } } if ( resourceDownloadRequests.empty() ) // No requests were queued { qDebug() << tr( "The referenced resource doesn't exist." ); return; } else resourceDownloadFinished(); // Check any requests finished already } else if ( url.scheme() == "gdprg" ) { // Program. Run it. QString id( url.host() ); for ( const auto & program : cfg.programs ) { if ( program.id == id ) { // Found the corresponding program. Programs::RunInstance * req = new Programs::RunInstance; connect( req, &Programs::RunInstance::finished, req, &QObject::deleteLater ); QString error; // Delete the request if it fails to start if ( !req->start( program, url.path().mid( 1 ), error ) ) { delete req; QMessageBox::critical( this, "GoldenDict", error ); } return; } } // Still here? No such program exists. QMessageBox::critical( this, "GoldenDict", tr( "The referenced audio program doesn't exist." ) ); } else if ( url.scheme() == "gdtts" ) { #ifndef NO_TTS_SUPPORT // Text to speech QString md5Id = Utils::Url::queryItemValue( url, "engine" ); QString text( url.path().mid( 1 ) ); for ( const auto & voiceEngine : cfg.voiceEngines ) { QString itemMd5Id = QString( QCryptographicHash::hash( voiceEngine.name.toUtf8(), QCryptographicHash::Md5 ).toHex() ); if ( itemMd5Id == md5Id ) { SpeechClient * speechClient = new SpeechClient( voiceEngine, this ); connect( speechClient, SIGNAL( finished() ), speechClient, SLOT( deleteLater() ) ); speechClient->tell( text ); break; } } #else qDebug() << "gdtts:// is not supported due to missing TTS support"; #endif } else if ( Utils::isExternalLink( url ) ) { // Use the system handler for the conventional external links QDesktopServices::openUrl( url ); } } ResourceToSaveHandler * ArticleView::saveResource( const QUrl & url, const QString & fileName ) { return saveResource( url, webview->url(), fileName ); } ResourceToSaveHandler * ArticleView::saveResource( const QUrl & url, const QUrl & ref, const QString & fileName ) { ResourceToSaveHandler * handler = new ResourceToSaveHandler( this, fileName ); sptr< Dictionary::DataRequest > req; if ( url.scheme() == "bres" || url.scheme() == "gico" || url.scheme() == "gdau" || url.scheme() == "gdvideo" ) { if ( url.host() == "search" ) { // Since searches should be limited to current group, we just do them // here ourselves since otherwise we'd need to pass group id to netmgr // and it should've been having knowledge of the current groups, too. unsigned currentGroup = getGroup( ref ); std::vector< sptr< Dictionary::Class > > const * activeDicts = nullptr; if ( groups.size() ) { for ( const auto & group : groups ) if ( group.id == currentGroup ) { activeDicts = &( group.dictionaries ); break; } } else activeDicts = &allDictionaries; if ( activeDicts ) { unsigned preferred = UINT_MAX; if ( url.hasFragment() && url.scheme() == "gdau" ) { // Find sound in the preferred dictionary QString preferredName = Utils::Url::fragment( url ); for ( unsigned x = 0; x < activeDicts->size(); ++x ) { try { if ( preferredName.compare( QString::fromUtf8( ( *activeDicts )[ x ]->getName().c_str() ) ) == 0 ) { preferred = x; sptr< Dictionary::DataRequest > data_request = ( *activeDicts )[ x ]->getResource( url.path().mid( 1 ).toUtf8().data() ); handler->addRequest( data_request ); if ( data_request->isFinished() && data_request->dataSize() > 0 ) { handler->downloadFinished(); return handler; } break; } } catch ( std::exception & e ) { gdWarning( "getResource request error (%s) in \"%s\"\n", e.what(), ( *activeDicts )[ x ]->getName().c_str() ); } } } for ( unsigned x = 0; x < activeDicts->size(); ++x ) { try { if ( x == preferred ) continue; req = ( *activeDicts )[ x ]->getResource( Utils::Url::path( url ).mid( 1 ).toUtf8().data() ); handler->addRequest( req ); if ( req->isFinished() && req->dataSize() > 0 ) { // Resource already found, stop next search break; } } catch ( std::exception & e ) { gdWarning( "getResource request error (%s) in \"%s\"\n", e.what(), ( *activeDicts )[ x ]->getName().c_str() ); } } } } else { // Normal resource download QString contentType; req = articleNetMgr.getResource( url, contentType ); if ( req.get() ) { handler->addRequest( req ); } } } else { req = std::make_shared< Dictionary::WebMultimediaDownload >( url, articleNetMgr ); handler->addRequest( req ); } if ( handler->isEmpty() ) // No requests were queued { emit statusBarMessage( tr( "ERROR: %1" ).arg( tr( "The referenced resource doesn't exist." ) ), 10000, QPixmap( ":/icons/error.svg" ) ); } // Check already finished downloads handler->downloadFinished(); return handler; } void ArticleView::updateMutedContents() { QUrl currentUrl = webview->url(); if ( currentUrl.scheme() != "gdlookup" ) return; // Weird url -- do nothing unsigned group = getGroup( currentUrl ); if ( !group ) return; // No group in url -- do nothing QString mutedDicts = getMutedForGroup( group ); if ( Utils::Url::queryItemValue( currentUrl, "muted" ) != mutedDicts ) { // The list has changed -- update the url Utils::Url::removeQueryItem( currentUrl, "muted" ); if ( mutedDicts.size() ) Utils::Url::addQueryItem( currentUrl, "muted", mutedDicts ); load( currentUrl ); //QApplication::setOverrideCursor( Qt::WaitCursor ); webview->setCursor( Qt::WaitCursor ); } } bool ArticleView::canGoBack() { return webview->history()->canGoBack(); } bool ArticleView::canGoForward() { return webview->history()->canGoForward(); } void ArticleView::setSelectionBySingleClick( bool set ) { webview->setSelectionBySingleClick( set ); } void ArticleView::setDelayedHighlightText( QString const & text ) { delayedHighlightText = text; } void ArticleView::syncBackgroundColorWithCfgDarkReader() const { // Only works Qt6.6.3+ https://bugreports.qt.io/browse/QTBUG-112013 #if QT_VERSION >= QT_VERSION_CHECK( 6, 6, 3 ) if ( cfg.preferences.darkReaderMode ) { webview->page()->setBackgroundColor( Qt::black ); } else { webview->page()->setBackgroundColor( Qt::white ); } #endif } void ArticleView::back() { // Don't allow navigating back to page 0, which is usually the initial // empty page if ( canGoBack() ) { currentActiveDictIds.clear(); historyMode = true; webview->back(); } } void ArticleView::forward() { currentActiveDictIds.clear(); historyMode = true; webview->forward(); } void ArticleView::handleAnkiAction() { // React to the "send *word* to anki" action. // If selected text is empty, use the whole article as the definition. if ( webview->selectedText().isEmpty() ) { makeAnkiCardFromArticle( getActiveArticleId() ); } else { sendToAnki( webview->title(), webview->selectedText(), translateLine->text() ); } } void ArticleView::reload() { webview->reload(); } void ArticleView::hasSound( const std::function< void( bool ) > & callback ) { webview->page()->runJavaScript( R"(if(typeof(gdAudioLinks)!="undefined") gdAudioLinks.first)", [ callback ]( const QVariant & v ) { bool has = false; if ( v.type() == QVariant::String ) has = !v.toString().isEmpty(); callback( has ); } ); } //use webengine javascript to playsound void ArticleView::playSound() { QString variable = R"( (function(){ var link=gdAudioMap.get(gdAudioLinks.current); if(link==undefined){ link=gdAudioLinks.first; } return link;})(); )"; webview->page()->runJavaScript( variable, [ this ]( const QVariant & result ) { if ( result.type() == QVariant::String ) { QString soundScript = result.toString(); if ( !soundScript.isEmpty() ) openLink( QUrl::fromEncoded( soundScript.toUtf8() ), webview->url() ); } } ); } void ArticleView::stopSound() { audioPlayer->stop(); } void ArticleView::toHtml( const std::function< void( QString & ) > & callback ) { webview->page()->toHtml( [ = ]( const QString & content ) { QString html = content; callback( html ); } ); } void ArticleView::setHtml( const QString & content, const QUrl & baseUrl ) { webview->page()->setHtml( content, baseUrl ); } void ArticleView::setContent( const QByteArray & data, const QString & mimeType, const QUrl & baseUrl ) { webview->page()->setContent( data, mimeType, baseUrl ); } QString ArticleView::getTitle() { return webview->page()->title(); } QString ArticleView::getWord() const { return currentWord; } void ArticleView::print( QPrinter * printer ) const { QEventLoop loop; bool result; auto printPreview = [ & ]( bool success ) { result = success; loop.quit(); }; #if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) webview->page()->print( printer, std::move( printPreview ) ); #else connect( webview, &QWebEngineView::printFinished, &loop, std::move( printPreview ) ); webview->print( printer ); #endif loop.exec(); if ( !result ) { qDebug() << "print failed"; } } void ArticleView::contextMenuRequested( QPoint const & pos ) { // Is that a link? Is there a selection? QWebEnginePage * r = webview->page(); QMenu menu( this ); QAction * followLink = nullptr; QAction * followLinkExternal = nullptr; QAction * followLinkNewTab = nullptr; QAction * lookupSelection = nullptr; QAction * lookupSelectionGr = nullptr; QAction * lookupSelectionNewTab = nullptr; QAction * lookupSelectionNewTabGr = nullptr; QAction * maxDictionaryRefsAction = nullptr; QAction * addWordToHistoryAction = nullptr; QAction * addHeaderToHistoryAction = nullptr; QAction * sendWordToInputLineAction = nullptr; QAction * saveImageAction = nullptr; QAction * openImageAction = nullptr; QAction * saveSoundAction = nullptr; QAction * saveBookmark = nullptr; #if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) const QWebEngineContextMenuData * menuData = &( r->contextMenuData() ); #else QWebEngineContextMenuRequest * menuData = webview->lastContextMenuRequest(); #endif QUrl targetUrl( menuData->linkUrl() ); Contexts contexts; tryMangleWebsiteClickedUrl( targetUrl, contexts ); if ( !targetUrl.isEmpty() ) { if ( !Utils::isExternalLink( targetUrl ) ) { followLink = new QAction( tr( "Op&en Link" ), &menu ); menu.addAction( followLink ); if ( !popupView && !isAudioLink( targetUrl ) ) { followLinkNewTab = new QAction( QIcon( ":/icons/addtab.svg" ), tr( "Open Link in New &Tab" ), &menu ); menu.addAction( followLinkNewTab ); } } if ( Utils::isExternalLink( targetUrl ) ) { followLinkExternal = new QAction( tr( "Open Link in &External Browser" ), &menu ); menu.addAction( followLinkExternal ); menu.addAction( webview->pageAction( QWebEnginePage::CopyLinkToClipboard ) ); } } QUrl imageUrl; #if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) if ( !popupView && menuData->mediaType() == QWebEngineContextMenuData::MediaTypeImage ) #else if ( !popupView && menuData->mediaType() == QWebEngineContextMenuRequest::MediaType::MediaTypeImage ) #endif { imageUrl = menuData->mediaUrl(); if ( !imageUrl.isEmpty() ) { menu.addAction( webview->pageAction( QWebEnginePage::CopyImageToClipboard ) ); saveImageAction = new QAction( tr( "Save &image..." ), &menu ); menu.addAction( saveImageAction ); openImageAction = new QAction( tr( "Open image in system viewer..." ), &menu ); menu.addAction( openImageAction ); } } if ( !popupView && isAudioLink( targetUrl ) ) { saveSoundAction = new QAction( tr( "Save s&ound..." ), &menu ); menu.addAction( saveSoundAction ); } QString const selectedText = webview->selectedText(); QString text = Utils::trimNonChar( selectedText ); if ( text.size() && text.size() < 60 ) { // We don't prompt for selections larger or equal to 60 chars, since // it ruins the menu and it's hardly a single word anyway. lookupSelection = new QAction( tr( "&Look up \"%1\"" ).arg( text ), &menu ); menu.addAction( lookupSelection ); if ( !popupView ) { lookupSelectionNewTab = new QAction( QIcon( ":/icons/addtab.svg" ), tr( "Look up \"%1\" in &New Tab" ).arg( text ), &menu ); menu.addAction( lookupSelectionNewTab ); sendWordToInputLineAction = new QAction( tr( "Send \"%1\" to input line" ).arg( text ), &menu ); menu.addAction( sendWordToInputLineAction ); } addWordToHistoryAction = new QAction( tr( "&Add \"%1\" to history" ).arg( text ), &menu ); menu.addAction( addWordToHistoryAction ); Instances::Group const * altGroup = ( currentGroupId != getGroup( webview->url() ) ) ? groups.findGroup( currentGroupId ) : nullptr; if ( altGroup ) { QIcon icon = altGroup->icon.size() ? QIcon( ":/flags/" + altGroup->icon ) : QIcon(); lookupSelectionGr = new QAction( icon, tr( "Look up \"%1\" in %2" ).arg( text ).arg( altGroup->name ), &menu ); menu.addAction( lookupSelectionGr ); if ( !popupView ) { lookupSelectionNewTabGr = new QAction( QIcon( ":/icons/addtab.svg" ), tr( "Look up \"%1\" in %2 in &New Tab" ).arg( text ).arg( altGroup->name ), &menu ); menu.addAction( lookupSelectionNewTabGr ); } } } if ( text.size() ) { // avoid too long in the menu ,use left 30 characters. saveBookmark = new QAction( tr( "Save &Bookmark \"%1...\"" ).arg( text.left( 30 ) ), &menu ); menu.addAction( saveBookmark ); } // Add anki menu (if enabled) // If there is no selected text, it will extract text from the current article. if ( cfg.preferences.ankiConnectServer.enabled ) { menu.addAction( &sendToAnkiAction ); sendToAnkiAction.setText( webview->selectedText().isEmpty() ? tr( "&Send Current Article to Anki" ) : tr( "&Send selected text to Anki" ) ); } if ( text.isEmpty() && !cfg.preferences.storeHistory ) { QString txt = webview->title(); if ( txt.size() > 60 ) txt = txt.left( 60 ) + "..."; addHeaderToHistoryAction = new QAction( tr( "&Add \"%1\" to history" ).arg( txt ), &menu ); menu.addAction( addHeaderToHistoryAction ); } if ( selectedText.size() ) { menu.addAction( webview->pageAction( QWebEnginePage::Copy ) ); menu.addAction( ©AsTextAction ); } else { menu.addAction( &selectCurrentArticleAction ); menu.addAction( webview->pageAction( QWebEnginePage::SelectAll ) ); } map< QAction *, QString > tableOfContents; // Add table of contents QStringList ids = getArticlesList(); if ( !menu.isEmpty() && ids.size() ) menu.addSeparator(); unsigned refsAdded = 0; bool maxDictionaryRefsReached = false; for ( QStringList::const_iterator i = ids.constBegin(); i != ids.constEnd(); ++i, ++refsAdded ) { // Find this dictionary for ( unsigned x = allDictionaries.size(); x--; ) { if ( allDictionaries[ x ]->getId() == i->toUtf8().data() ) { QAction * action = nullptr; if ( refsAdded == cfg.preferences.maxDictionaryRefsInContextMenu ) { // Enough! Or the menu would become too large. maxDictionaryRefsAction = new QAction( ".........", &menu ); action = maxDictionaryRefsAction; maxDictionaryRefsReached = true; } else { action = new QAction( allDictionaries[ x ]->getIcon(), QString::fromUtf8( allDictionaries[ x ]->getName().c_str() ), &menu ); // Force icons in menu on all platforms, // since without them it will be much harder // to find things. action->setIconVisibleInMenu( true ); } menu.addAction( action ); tableOfContents[ action ] = *i; break; } } if ( maxDictionaryRefsReached ) break; } menu.addSeparator(); if ( !popupView || cfg.pinPopupWindow ) menu.addAction( &inspectAction ); if ( !menu.isEmpty() ) { connect( this, &ArticleView::closePopupMenu, &menu, &QWidget::close ); QAction * result = menu.exec( webview->mapToGlobal( pos ) ); if ( !result ) return; if ( result == followLink ) openLink( targetUrl, webview->url(), getCurrentArticle(), contexts ); else if ( result == followLinkExternal ) QDesktopServices::openUrl( targetUrl ); else if ( result == lookupSelection ) showDefinition( text, getGroup( webview->url() ), getCurrentArticle() ); else if ( result == saveBookmark ) { emit saveBookmarkSignal( text.left( 60 ) ); } else if ( result == &sendToAnkiAction ) { // This action is handled by a slot. return; } else if ( result == lookupSelectionGr && currentGroupId ) showDefinition( selectedText, currentGroupId, QString() ); else if ( result == addWordToHistoryAction ) emit forceAddWordToHistory( selectedText ); else if ( result == addHeaderToHistoryAction ) emit forceAddWordToHistory( webview->title() ); else if ( result == sendWordToInputLineAction ) emit sendWordToInputLine( selectedText ); else if ( !popupView && result == followLinkNewTab ) emit openLinkInNewTab( targetUrl, webview->url(), getCurrentArticle(), contexts ); else if ( !popupView && result == lookupSelectionNewTab ) emit showDefinitionInNewTab( selectedText, getGroup( webview->url() ), getCurrentArticle(), Contexts() ); else if ( !popupView && result == lookupSelectionNewTabGr && currentGroupId ) emit showDefinitionInNewTab( selectedText, currentGroupId, QString(), Contexts() ); else if ( result == saveImageAction || result == saveSoundAction ) { QUrl url = ( result == saveImageAction ) ? imageUrl : targetUrl; QString savePath; QString fileName; if ( cfg.resourceSavePath.isEmpty() ) savePath = QDir::homePath(); else { savePath = QDir::fromNativeSeparators( cfg.resourceSavePath ); if ( !QDir( savePath ).exists() ) savePath = QDir::homePath(); } QString name = Utils::Url::path( url ).section( '/', -1 ); if ( result == saveSoundAction ) { // Audio data // if ( name.indexOf( '.' ) < 0 ) // name += ".wav"; fileName = savePath + "/" + name; fileName = QFileDialog::getSaveFileName( parentWidget(), tr( "Save sound" ), fileName, tr( "Sound files (*.wav *.opus *.ogg *.oga *.mp3 *.mp4 *.aac *.flac *.mid *.wv *.ape *.spx);;All files (*.*)" ) ); } else { // Image data // Check for babylon image name if ( name[ 0 ] == '\x1E' ) name.remove( 0, 1 ); if ( name.length() && name[ name.length() - 1 ] == '\x1F' ) name.chop( 1 ); fileName = savePath + "/" + name; fileName = QFileDialog::getSaveFileName( parentWidget(), tr( "Save image" ), fileName, tr( "Image files (*.bmp *.jpg *.png *.tif);;All files (*.*)" ) ); } if ( !fileName.isEmpty() ) { QFileInfo fileInfo( fileName ); emit storeResourceSavePath( QDir::toNativeSeparators( fileInfo.absoluteDir().absolutePath() ) ); saveResource( url, webview->url(), fileName ); } } else if ( result == openImageAction ) { QUrl url = imageUrl; QString fileName; QString name = Utils::Url::path( url ).section( '/', -1 ); // Image data // Check for babylon image name if ( name[ 0 ] == '\x1E' ) name.remove( 0, 1 ); if ( name.length() && name[ name.length() - 1 ] == '\x1F' ) name.chop( 1 ); fileName = QDir::temp().filePath( QString::number( QRandomGenerator::global()->generate() ) + name ); if ( !fileName.isEmpty() ) { QFileInfo fileInfo( fileName ); auto handler = saveResource( url, webview->url(), fileName ); if ( !handler->isEmpty() ) { connect( handler, &ResourceToSaveHandler::done, this, [ fileName ]() { QDesktopServices::openUrl( QUrl::fromLocalFile( fileName ) ); } ); } else { QDesktopServices::openUrl( QUrl::fromLocalFile( fileName ) ); } } } else { if ( !popupView && result == maxDictionaryRefsAction ) emit showDictsPane(); // Match against table of contents QString id = tableOfContents[ result ]; if ( id.size() ) setCurrentArticle( scrollToFromDictionaryId( id ), true ); } } qDebug() << "title = " << r->title(); } void ArticleView::resourceDownloadFinished() { if ( resourceDownloadRequests.empty() ) return; // Stray signal // Find any finished resources for ( list< sptr< Dictionary::DataRequest > >::iterator i = resourceDownloadRequests.begin(); i != resourceDownloadRequests.end(); ) { if ( ( *i )->isFinished() ) { if ( ( *i )->dataSize() >= 0 ) { // Ok, got one finished, all others are irrelevant now vector< char > const & data = ( *i )->getFullData(); if ( resourceDownloadUrl.scheme() == "gdau" || Utils::Url::isWebAudioUrl( resourceDownloadUrl ) ) { // Audio data audioPlayer->stop(); connect( audioPlayer.data(), &AudioPlayerInterface::error, this, &ArticleView::audioPlayerError, Qt::UniqueConnection ); QString errorMessage = audioPlayer->play( data.data(), data.size() ); if ( !errorMessage.isEmpty() ) QMessageBox::critical( this, "GoldenDict", tr( "Failed to play sound file: %1" ).arg( errorMessage ) ); } else { // Create a temporary file // Remove the ones previously used, if any cleanupTemp(); QString fileName; { QTemporaryFile tmp( QDir::temp().filePath( "XXXXXX-" + resourceDownloadUrl.path().section( '/', -1 ) ), this ); if ( !tmp.open() || (size_t)tmp.write( &data.front(), data.size() ) != data.size() ) { QMessageBox::critical( this, "GoldenDict", tr( "Failed to create temporary file." ) ); return; } tmp.setAutoRemove( false ); desktopOpenedTempFiles.insert( fileName = tmp.fileName() ); } if ( !QDesktopServices::openUrl( QUrl::fromLocalFile( fileName ) ) ) QMessageBox::critical( this, "GoldenDict", tr( "Failed to auto-open resource file, try opening manually: %1." ).arg( fileName ) ); } // Ok, whatever it was, it's finished. Remove this and any other // requests and finish. resourceDownloadRequests.clear(); return; } else { // This one had no data. Erase it. resourceDownloadRequests.erase( i++ ); } } else // Unfinished, wait. break; } if ( resourceDownloadRequests.empty() ) { // emit statusBarMessage( // tr("WARNING: %1").arg(tr("The referenced resource failed to download.")), // 10000, QPixmap(":/icons/error.svg")); } } void ArticleView::audioPlayerError( QString const & message ) { emit statusBarMessage( tr( "WARNING: Audio Player: %1" ).arg( message ), 10000, QPixmap( ":/icons/error.svg" ) ); } void ArticleView::pasteTriggered() { QString word = cfg.preferences.sanitizeInputPhrase( QApplication::clipboard()->text() ); if ( !word.isEmpty() ) { unsigned groupId = getGroup( webview->url() ); if ( groupId == 0 || groupId == Instances::Group::HelpGroupId ) { // We couldn't figure out the group out of the URL, // so let's try the currently selected group. groupId = currentGroupId; } showDefinition( word, groupId, getCurrentArticle() ); } } unsigned ArticleView::getCurrentGroup() { return currentGroupId; } void ArticleView::moveOneArticleUp() { QString current = getCurrentArticle(); if ( current.size() ) { QStringList lst = getArticlesList(); int idx = lst.indexOf( dictionaryIdFromScrollTo( current ) ); if ( idx != -1 ) { --idx; if ( idx < 0 ) idx = lst.size() - 1; setCurrentArticle( scrollToFromDictionaryId( lst[ idx ] ), true ); } } } void ArticleView::moveOneArticleDown() { QString currentDictId = getActiveArticleId(); QStringList lst = getArticlesList(); // if current article is empty .use the first as default. if ( currentDictId.isEmpty() && !lst.isEmpty() ) { currentDictId = lst[ 0 ]; } int idx = lst.indexOf( currentDictId ); if ( idx != -1 ) { idx = ( idx + 1 ) % lst.size(); setCurrentArticle( scrollToFromDictionaryId( lst[ idx ] ), true ); } } void ArticleView::openSearch() { if ( !isVisible() ) return; if ( ftsSearchIsOpened ) closeSearch(); if ( !searchIsOpened ) { searchPanel->show(); searchPanel->lineEdit->setText( getTitle() ); searchIsOpened = true; } searchPanel->lineEdit->setFocus(); searchPanel->lineEdit->selectAll(); // Clear any current selection if ( !webview->selectedText().isEmpty() ) { webview->findText( "" ); } Utils::Widget::setNoResultColor( searchPanel->lineEdit, false ); } void ArticleView::on_searchPrevious_clicked() { if ( searchIsOpened ) performFindOperation( true ); } void ArticleView::on_searchNext_clicked() { if ( searchIsOpened ) performFindOperation( false ); } void ArticleView::on_searchText_textEdited() { performFindOperation( false ); } void ArticleView::on_searchText_returnPressed() { on_searchNext_clicked(); } void ArticleView::on_searchCloseButton_clicked() { closeSearch(); } void ArticleView::on_searchCaseSensitive_clicked() { performFindOperation( false ); } //the id start with "gdform-" void ArticleView::onJsActiveArticleChanged( QString const & id ) { if ( !isScrollTo( id ) ) return; // Incorrect id QString dictId = dictionaryIdFromScrollTo( id ); setActiveArticleId( dictId ); emit activeArticleChanged( this, dictId ); } void ArticleView::doubleClicked( QPoint pos ) { // We might want to initiate translation of the selected word audioPlayer->stop(); if ( cfg.preferences.doubleClickTranslates ) { QString selectedText = webview->selectedText(); // ignore empty word; if ( selectedText.isEmpty() ) return; emit sendWordToInputLine( selectedText ); // Do some checks to make sure there's a sensible selection indeed if ( Folding::applyWhitespaceOnly( gd::toWString( selectedText ) ).size() && selectedText.size() < 60 ) { // Initiate translation Qt::KeyboardModifiers kmod = QApplication::keyboardModifiers(); if ( kmod & ( Qt::ControlModifier | Qt::ShiftModifier ) ) { // open in new tab emit showDefinitionInNewTab( selectedText, getGroup( webview->url() ), getCurrentArticle(), Contexts() ); } else { QUrl const & ref = webview->url(); if ( Utils::Url::hasQueryItem( ref, "dictionaries" ) ) { QStringList dictsList = Utils::Url::queryItemValue( ref, "dictionaries" ).split( ",", Qt::SkipEmptyParts ); showDefinition( selectedText, dictsList, QRegExp(), getGroup( ref ), false ); } else showDefinition( selectedText, getGroup( ref ), getCurrentArticle() ); } } } } void ArticleView::performFindOperation( bool backwards ) { QString text = searchPanel->lineEdit->text(); QWebEnginePage::FindFlags f( 0 ); if ( searchPanel->caseSensitive->isChecked() ) f |= QWebEnginePage::FindCaseSensitively; if ( backwards ) f |= QWebEnginePage::FindBackward; findText( text, f, [ text, this ]( bool match ) { bool setMark = !text.isEmpty() && !match; Utils::Widget::setNoResultColor( searchPanel->lineEdit, setMark ); } ); } void ArticleView::findText( QString & text, const QWebEnginePage::FindFlags & f, const std::function< void( bool match ) > & callback ) { #if ( QT_VERSION >= QT_VERSION_CHECK( 6, 0, 0 ) ) webview->findText( text, f, [ callback ]( const QWebEngineFindTextResult & result ) { auto r = result.numberOfMatches() > 0; if ( callback ) callback( r ); } ); #else webview->findText( text, f, [ callback ]( bool result ) { if ( callback ) callback( result ); } ); #endif } bool ArticleView::closeSearch() { if ( searchIsOpened ) { searchPanel->hide(); webview->setFocus(); searchIsOpened = false; return true; } else if ( ftsSearchIsOpened ) { firstAvailableText.clear(); uniqueMatches.clear(); ftsSearchIsOpened = false; ftsSearchPanel->hide(); webview->setFocus(); QWebEnginePage::FindFlags flags( 0 ); webview->findText( "", flags ); return true; } else return false; } bool ArticleView::isSearchOpened() { return searchIsOpened; } void ArticleView::showEvent( QShowEvent * ev ) { QWidget::showEvent( ev ); if ( !searchIsOpened ) searchPanel->hide(); if ( !ftsSearchIsOpened ) ftsSearchPanel->hide(); } void ArticleView::copyAsText() { QString text = webview->selectedText(); if ( !text.isEmpty() ) QApplication::clipboard()->setText( text ); } void ArticleView::highlightFTSResults() { closeSearch(); // Clear any current selection webview->findText( "" ); QString regString = Utils::Url::queryItemValue( webview->url(), "regexp" ); if ( regString.isEmpty() ) return; //replace any unicode Number ,Symbol ,Punctuation ,Mark character to whitespace regString.replace( QRegularExpression( R"([\p{N}\p{S}\p{P}\p{M}])", QRegularExpression::UseUnicodePropertiesOption ), " " ); if ( regString.trimmed().isEmpty() ) return; QString script = QString( "var context = document.querySelector(\"body\");\n" "var instance = new Mark(context);\n" "instance.mark(\"%1\",{\"accuracy\": \"exactly\"});" ) .arg( regString ); webview->page()->runJavaScript( script ); } void ArticleView::setActiveDictIds( const ActiveDictIds & ad ) { auto groupId = ad.groupId; if ( groupId == 0 ) { groupId = Instances::Group::AllGroupId; } if ( ( ad.word == currentWord && groupId == getCurrentGroup() ) || historyMode ) { // ignore all other signals. qDebug() << "receive dicts, current word:" << currentWord << ad.word << ":" << ad.dictIds; currentActiveDictIds << ad.dictIds; currentActiveDictIds.removeDuplicates(); emit updateFoundInDictsList(); } } void ArticleView::dictionaryClear( const ActiveDictIds & ad ) { auto groupId = ad.groupId; if ( groupId == 0 ) { groupId = Instances::Group::AllGroupId; } // ignore all other signals. if ( ad.word == currentWord && groupId == getCurrentGroup() ) { qDebug() << "clear current dictionaries:" << currentWord; currentActiveDictIds.clear(); } } void ArticleView::performFtsFindOperation( bool backwards ) { if ( !ftsSearchIsOpened ) return; if ( firstAvailableText.isEmpty() ) { ftsSearchPanel->statusLabel->setText( searchStatusMessageNoMatches() ); ftsSearchPanel->next->setEnabled( false ); ftsSearchPanel->previous->setEnabled( false ); return; } QWebEnginePage::FindFlags flags( 0 ); if ( ftsSearchMatchCase ) flags |= QWebEnginePage::FindCaseSensitively; if ( backwards ) { #if ( QT_VERSION >= QT_VERSION_CHECK( 6, 0, 0 ) ) webview->findText( firstAvailableText, flags | QWebEnginePage::FindBackward, [ this ]( const QWebEngineFindTextResult & result ) { if ( result.numberOfMatches() == 0 ) return; ftsSearchPanel->previous->setEnabled( true ); if ( !ftsSearchPanel->next->isEnabled() ) ftsSearchPanel->next->setEnabled( true ); ftsSearchPanel->statusLabel->setText( searchStatusMessage( result.activeMatch(), result.numberOfMatches() ) ); } ); #else webview->findText( firstAvailableText, flags | QWebEnginePage::FindBackward, [ this ]( bool res ) { ftsSearchPanel->previous->setEnabled( res ); if ( !ftsSearchPanel->next->isEnabled() ) ftsSearchPanel->next->setEnabled( res ); } ); #endif } else { #if ( QT_VERSION >= QT_VERSION_CHECK( 6, 0, 0 ) ) webview->findText( firstAvailableText, flags, [ this ]( const QWebEngineFindTextResult & result ) { if ( result.numberOfMatches() == 0 ) return; ftsSearchPanel->next->setEnabled( true ); if ( !ftsSearchPanel->previous->isEnabled() ) ftsSearchPanel->previous->setEnabled( true ); ftsSearchPanel->statusLabel->setText( searchStatusMessage( result.activeMatch(), result.numberOfMatches() ) ); } ); #else webview->findText( firstAvailableText, flags, [ this ]( bool res ) { ftsSearchPanel->next->setEnabled( res ); if ( !ftsSearchPanel->previous->isEnabled() ) ftsSearchPanel->previous->setEnabled( res ); } ); #endif } } void ArticleView::on_ftsSearchPrevious_clicked() { performFtsFindOperation( true ); } void ArticleView::on_ftsSearchNext_clicked() { performFtsFindOperation( false ); } void ArticleView::clearContent() { auto html = articleNetMgr.getHtml( ResourceType::BLANK ); webview->setHtml( QString::fromStdString( html ) ); } ResourceToSaveHandler::ResourceToSaveHandler( ArticleView * view, QString fileName ): QObject( view ), fileName( std::move( fileName ) ), alreadyDone( false ) { connect( this, &ResourceToSaveHandler::statusBarMessage, view, &ArticleView::statusBarMessage ); } void ResourceToSaveHandler::addRequest( const sptr< Dictionary::DataRequest > & req ) { if ( !alreadyDone ) { downloadRequests.push_back( req ); connect( req.get(), &Dictionary::Request::finished, this, &ResourceToSaveHandler::downloadFinished ); } } void ResourceToSaveHandler::downloadFinished() { if ( downloadRequests.empty() ) return; // Stray signal // Find any finished resources for ( auto i = downloadRequests.begin(); i != downloadRequests.end(); ) { if ( ( *i )->isFinished() ) { if ( ( *i )->dataSize() >= 0 && !alreadyDone ) { QByteArray resourceData; vector< char > const & data = ( *i )->getFullData(); resourceData = QByteArray( data.data(), data.size() ); // Write data to file if ( !fileName.isEmpty() ) { const QFileInfo fileInfo( fileName ); QDir().mkpath( fileInfo.absoluteDir().absolutePath() ); QFile file( fileName ); if ( file.open( QFile::WriteOnly ) ) { file.write( resourceData.data(), resourceData.size() ); file.close(); } if ( file.error() ) { emit statusBarMessage( tr( "ERROR: %1" ).arg( tr( "Resource saving error: " ) + file.errorString() ), 10000, QPixmap( ":/icons/error.svg" ) ); } } alreadyDone = true; // Clear other requests downloadRequests.clear(); break; } else { // This one had no data. Erase it. downloadRequests.erase( i++ ); } } else // Unfinished, wait. break; } if ( downloadRequests.empty() ) { if ( !alreadyDone ) { emit statusBarMessage( tr( "WARNING: %1" ).arg( tr( "The referenced resource failed to download." ) ), 10000, QPixmap( ":/icons/error.svg" ) ); } emit done(); deleteLater(); } } ArticleViewAgent::ArticleViewAgent( ArticleView * articleView ): QObject( articleView ), articleView( articleView ) { } void ArticleViewAgent::onJsActiveArticleChanged( QString const & id ) { articleView->onJsActiveArticleChanged( id ); } void ArticleViewAgent::linkClickedInHtml( QUrl const & url ) { articleView->linkClickedInHtml( url ); } void ArticleViewAgent::collapseInHtml( QString const & dictId, bool on ) const { if ( GlobalBroadcaster::instance()->getPreference()->sessionCollapse ) { if ( on ) { GlobalBroadcaster::instance()->collapsedDicts.insert( dictId ); } else { GlobalBroadcaster::instance()->collapsedDicts.remove( dictId ); } } }