File indexing completed on 2024-12-22 04:28:12

0001 /*
0002    SPDX-FileCopyrightText: 2023-2024 Laurent Montel <montel.org>
0003 
0004    SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "richtextbrowser.h"
0008 
0009 #include "widgets/textmessageindicator.h"
0010 #include <KCursor>
0011 #include <KLocalizedString>
0012 #include <KMessageBox>
0013 #include <KStandardAction>
0014 #include <KStandardGuiItem>
0015 #include <QIcon>
0016 
0017 #include "config-textcustomeditor.h"
0018 #include <KIO/KUriFilterSearchProviderActions>
0019 #if HAVE_KTEXTADDONS_TEXT_TO_SPEECH_SUPPORT
0020 #include <TextEditTextToSpeech/TextToSpeech>
0021 #endif
0022 
0023 #include <KColorScheme>
0024 #include <QApplication>
0025 #include <QClipboard>
0026 #include <QContextMenuEvent>
0027 #include <QMenu>
0028 #include <QScrollBar>
0029 #include <QTextBlock>
0030 #include <QTextCursor>
0031 #include <QTextDocumentFragment>
0032 
0033 using namespace TextCustomEditor;
0034 class Q_DECL_HIDDEN RichTextBrowser::RichTextBrowserPrivate
0035 {
0036 public:
0037     RichTextBrowserPrivate(RichTextBrowser *qq)
0038         : q(qq)
0039         , textIndicator(new TextCustomEditor::TextMessageIndicator(q))
0040         , webshortcutMenuManager(new KIO::KUriFilterSearchProviderActions(q))
0041     {
0042         supportFeatures |= RichTextBrowser::Search;
0043         supportFeatures |= RichTextBrowser::TextToSpeech;
0044         supportFeatures |= RichTextBrowser::AllowWebShortcut;
0045 
0046         // Workaround QTextEdit behavior: if the cursor points right after the link
0047         // and start typing, the char format is kept. If user wants to write normal
0048         // text right after the link, the only way is to move cursor at the next character
0049         // (say for "<a>text</a>more text" the character has to be before letter "o"!)
0050         // It's impossible if the whole document ends with a link.
0051         // The same happens when text starts with a link: it's impossible to write normal text before it.
0052         QObject::connect(q, &RichTextBrowser::cursorPositionChanged, q, [this]() {
0053             QTextCursor c = q->textCursor();
0054             if (c.charFormat().isAnchor() && !c.hasSelection()) {
0055                 QTextCharFormat fmt;
0056                 // If we are at block start or end (and at anchor), we just set the "default" format
0057                 if (!c.atBlockEnd() && !c.atBlockStart() && !c.hasSelection()) {
0058                     QTextCursor probe = c;
0059                     // Otherwise, if the next character is not a link, we just grab it's format
0060                     probe.movePosition(QTextCursor::NextCharacter);
0061                     if (!probe.charFormat().isAnchor()) {
0062                         fmt = probe.charFormat();
0063                     }
0064                 }
0065                 c.setCharFormat(fmt);
0066                 q->setTextCursor(c);
0067             }
0068         });
0069     }
0070 
0071     ~RichTextBrowserPrivate()
0072     {
0073     }
0074 
0075     RichTextBrowser *const q;
0076     TextCustomEditor::TextMessageIndicator *const textIndicator;
0077     QTextDocumentFragment originalDoc;
0078     KIO::KUriFilterSearchProviderActions *const webshortcutMenuManager;
0079     RichTextBrowser::SupportFeatures supportFeatures;
0080     QColor mReadOnlyBackgroundColor;
0081     int mInitialFontSize;
0082     bool customPalette = false;
0083 };
0084 
0085 RichTextBrowser::RichTextBrowser(QWidget *parent)
0086     : QTextBrowser(parent)
0087     , d(new RichTextBrowserPrivate(this))
0088 {
0089     setAcceptRichText(true);
0090     KCursor::setAutoHideCursor(this, true, false);
0091     d->mInitialFontSize = font().pointSize();
0092     regenerateColorScheme();
0093 }
0094 
0095 RichTextBrowser::~RichTextBrowser() = default;
0096 
0097 void RichTextBrowser::regenerateColorScheme()
0098 {
0099     d->mReadOnlyBackgroundColor = KColorScheme(QPalette::Disabled, KColorScheme::View).background().color();
0100     updateReadOnlyColor();
0101 }
0102 
0103 void RichTextBrowser::setDefaultFontSize(int val)
0104 {
0105     d->mInitialFontSize = val;
0106     slotZoomReset();
0107 }
0108 
0109 void RichTextBrowser::slotDisplayMessageIndicator(const QString &message)
0110 {
0111     d->textIndicator->display(message);
0112 }
0113 
0114 void RichTextBrowser::contextMenuEvent(QContextMenuEvent *event)
0115 {
0116     QMenu *popup = mousePopupMenu(event->pos());
0117     if (popup) {
0118         popup->exec(event->globalPos());
0119         delete popup;
0120     }
0121 }
0122 
0123 QMenu *RichTextBrowser::mousePopupMenu(QPoint pos)
0124 {
0125     QMenu *popup = createStandardContextMenu();
0126     if (popup) {
0127         const bool emptyDocument = document()->isEmpty();
0128         if (!isReadOnly()) {
0129             const QList<QAction *> actionList = popup->actions();
0130             enum { UndoAct, RedoAct, CutAct, CopyAct, PasteAct, ClearAct, SelectAllAct, NCountActs };
0131             QAction *separatorAction = nullptr;
0132             const int idx = actionList.indexOf(actionList[SelectAllAct]) + 1;
0133             if (idx < actionList.count()) {
0134                 separatorAction = actionList.at(idx);
0135             }
0136             if (separatorAction) {
0137                 QAction *clearAllAction = KStandardAction::clear(this, &RichTextBrowser::slotUndoableClear, popup);
0138                 if (emptyDocument) {
0139                     clearAllAction->setEnabled(false);
0140                 }
0141                 popup->insertAction(separatorAction, clearAllAction);
0142             }
0143         }
0144         if (searchSupport()) {
0145             popup->addSeparator();
0146             QAction *findAction = KStandardAction::find(this, &RichTextBrowser::findText, popup);
0147             popup->addAction(findAction);
0148             if (emptyDocument) {
0149                 findAction->setEnabled(false);
0150             }
0151         } else {
0152             popup->addSeparator();
0153         }
0154 
0155 #if HAVE_KTEXTADDONS_TEXT_TO_SPEECH_SUPPORT
0156         if (!emptyDocument) {
0157             QAction *speakAction = popup->addAction(i18n("Speak Text"));
0158             speakAction->setIcon(QIcon::fromTheme(QStringLiteral("preferences-desktop-text-to-speech")));
0159             connect(speakAction, &QAction::triggered, this, &RichTextBrowser::slotSpeakText);
0160         }
0161 #endif
0162         if (webShortcutSupport() && textCursor().hasSelection()) {
0163             popup->addSeparator();
0164             const QString selectedText = textCursor().selectedText();
0165             d->webshortcutMenuManager->setSelectedText(selectedText);
0166             d->webshortcutMenuManager->addWebShortcutsToMenu(popup);
0167         }
0168         addExtraMenuEntry(popup, pos);
0169         return popup;
0170     }
0171     return nullptr;
0172 }
0173 
0174 void RichTextBrowser::slotSpeakText()
0175 {
0176     QString text;
0177     if (textCursor().hasSelection()) {
0178         text = textCursor().selectedText();
0179     } else {
0180         text = toPlainText();
0181     }
0182     Q_EMIT say(text);
0183 }
0184 
0185 void RichTextBrowser::setWebShortcutSupport(bool b)
0186 {
0187     if (b) {
0188         d->supportFeatures |= AllowWebShortcut;
0189     } else {
0190         d->supportFeatures = (d->supportFeatures & ~AllowWebShortcut);
0191     }
0192 }
0193 
0194 bool RichTextBrowser::webShortcutSupport() const
0195 {
0196     return d->supportFeatures & AllowWebShortcut;
0197 }
0198 
0199 void RichTextBrowser::setSearchSupport(bool b)
0200 {
0201     if (b) {
0202         d->supportFeatures |= Search;
0203     } else {
0204         d->supportFeatures = (d->supportFeatures & ~Search);
0205     }
0206 }
0207 
0208 bool RichTextBrowser::searchSupport() const
0209 {
0210     return d->supportFeatures & Search;
0211 }
0212 
0213 void RichTextBrowser::setTextToSpeechSupport(bool b)
0214 {
0215     if (b) {
0216         d->supportFeatures |= TextToSpeech;
0217     } else {
0218         d->supportFeatures = (d->supportFeatures & ~TextToSpeech);
0219     }
0220 }
0221 
0222 bool RichTextBrowser::textToSpeechSupport() const
0223 {
0224     return d->supportFeatures & TextToSpeech;
0225 }
0226 
0227 void RichTextBrowser::addExtraMenuEntry(QMenu *menu, QPoint pos)
0228 {
0229     Q_UNUSED(menu)
0230     Q_UNUSED(pos)
0231 }
0232 
0233 void RichTextBrowser::slotUndoableClear()
0234 {
0235     QTextCursor cursor = textCursor();
0236     cursor.beginEditBlock();
0237     cursor.movePosition(QTextCursor::Start);
0238     cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
0239     cursor.removeSelectedText();
0240     cursor.endEditBlock();
0241 }
0242 
0243 void RichTextBrowser::updateReadOnlyColor()
0244 {
0245     if (isReadOnly()) {
0246         QPalette p = palette();
0247         p.setColor(QPalette::Base, d->mReadOnlyBackgroundColor);
0248         p.setColor(QPalette::Window, d->mReadOnlyBackgroundColor);
0249         setPalette(p);
0250     }
0251 }
0252 
0253 static void richTextDeleteWord(QTextCursor cursor, QTextCursor::MoveOperation op)
0254 {
0255     cursor.clearSelection();
0256     cursor.movePosition(op, QTextCursor::KeepAnchor);
0257     cursor.removeSelectedText();
0258 }
0259 
0260 void RichTextBrowser::deleteWordBack()
0261 {
0262     richTextDeleteWord(textCursor(), QTextCursor::PreviousWord);
0263 }
0264 
0265 void RichTextBrowser::deleteWordForward()
0266 {
0267     richTextDeleteWord(textCursor(), QTextCursor::WordRight);
0268 }
0269 
0270 bool RichTextBrowser::event(QEvent *ev)
0271 {
0272     if (ev->type() == QEvent::ShortcutOverride) {
0273         auto e = static_cast<QKeyEvent *>(ev);
0274         if (overrideShortcut(e)) {
0275             e->accept();
0276             return true;
0277         }
0278     } else if (ev->type() == QEvent::ApplicationPaletteChange) {
0279         regenerateColorScheme();
0280     }
0281     return QTextEdit::event(ev);
0282 }
0283 
0284 void RichTextBrowser::wheelEvent(QWheelEvent *event)
0285 {
0286     if (QApplication::keyboardModifiers() & Qt::ControlModifier) {
0287         const int angleDeltaY{event->angleDelta().y()};
0288         if (angleDeltaY > 0) {
0289             zoomIn();
0290         } else if (angleDeltaY < 0) {
0291             zoomOut();
0292         }
0293         event->accept();
0294         return;
0295     }
0296     QTextEdit::wheelEvent(event);
0297 }
0298 
0299 bool RichTextBrowser::handleShortcut(QKeyEvent *event)
0300 {
0301     const int key = event->key() | event->modifiers();
0302 
0303     if (KStandardShortcut::copy().contains(key)) {
0304         copy();
0305         return true;
0306     } else if (KStandardShortcut::paste().contains(key)) {
0307         paste();
0308         return true;
0309     } else if (KStandardShortcut::cut().contains(key)) {
0310         cut();
0311         return true;
0312     } else if (KStandardShortcut::undo().contains(key)) {
0313         if (!isReadOnly()) {
0314             undo();
0315         }
0316         return true;
0317     } else if (KStandardShortcut::redo().contains(key)) {
0318         if (!isReadOnly()) {
0319             redo();
0320         }
0321         return true;
0322     } else if (KStandardShortcut::deleteWordBack().contains(key)) {
0323         if (!isReadOnly()) {
0324             deleteWordBack();
0325         }
0326         return true;
0327     } else if (KStandardShortcut::deleteWordForward().contains(key)) {
0328         if (!isReadOnly()) {
0329             deleteWordForward();
0330         }
0331         return true;
0332     } else if (KStandardShortcut::backwardWord().contains(key)) {
0333         QTextCursor cursor = textCursor();
0334         cursor.movePosition(QTextCursor::PreviousWord);
0335         setTextCursor(cursor);
0336         return true;
0337     } else if (KStandardShortcut::forwardWord().contains(key)) {
0338         QTextCursor cursor = textCursor();
0339         cursor.movePosition(QTextCursor::NextWord);
0340         setTextCursor(cursor);
0341         return true;
0342     } else if (KStandardShortcut::next().contains(key)) {
0343         QTextCursor cursor = textCursor();
0344         bool moved = false;
0345         qreal lastY = cursorRect(cursor).bottom();
0346         qreal distance = 0;
0347         do {
0348             qreal y = cursorRect(cursor).bottom();
0349             distance += qAbs(y - lastY);
0350             lastY = y;
0351             moved = cursor.movePosition(QTextCursor::Down);
0352         } while (moved && distance < viewport()->height());
0353 
0354         if (moved) {
0355             cursor.movePosition(QTextCursor::Up);
0356             verticalScrollBar()->triggerAction(QAbstractSlider::SliderPageStepAdd);
0357         }
0358         setTextCursor(cursor);
0359         return true;
0360     } else if (KStandardShortcut::prior().contains(key)) {
0361         QTextCursor cursor = textCursor();
0362         bool moved = false;
0363         qreal lastY = cursorRect(cursor).bottom();
0364         qreal distance = 0;
0365         do {
0366             qreal y = cursorRect(cursor).bottom();
0367             distance += qAbs(y - lastY);
0368             lastY = y;
0369             moved = cursor.movePosition(QTextCursor::Up);
0370         } while (moved && distance < viewport()->height());
0371 
0372         if (moved) {
0373             cursor.movePosition(QTextCursor::Down);
0374             verticalScrollBar()->triggerAction(QAbstractSlider::SliderPageStepSub);
0375         }
0376         setTextCursor(cursor);
0377         return true;
0378     } else if (KStandardShortcut::begin().contains(key)) {
0379         QTextCursor cursor = textCursor();
0380         cursor.movePosition(QTextCursor::Start);
0381         setTextCursor(cursor);
0382         return true;
0383     } else if (KStandardShortcut::end().contains(key)) {
0384         QTextCursor cursor = textCursor();
0385         cursor.movePosition(QTextCursor::End);
0386         setTextCursor(cursor);
0387         return true;
0388     } else if (KStandardShortcut::beginningOfLine().contains(key)) {
0389         QTextCursor cursor = textCursor();
0390         cursor.movePosition(QTextCursor::StartOfLine);
0391         setTextCursor(cursor);
0392         return true;
0393     } else if (KStandardShortcut::endOfLine().contains(key)) {
0394         QTextCursor cursor = textCursor();
0395         cursor.movePosition(QTextCursor::EndOfLine);
0396         setTextCursor(cursor);
0397         return true;
0398     } else if (searchSupport() && KStandardShortcut::find().contains(key)) {
0399         Q_EMIT findText();
0400         return true;
0401     } else if (KStandardShortcut::pasteSelection().contains(key)) {
0402         QString text = QApplication::clipboard()->text(QClipboard::Selection);
0403         if (!text.isEmpty()) {
0404             insertPlainText(text); // TODO: check if this is html? (MiB)
0405         }
0406         return true;
0407     } else if (event == QKeySequence::DeleteEndOfLine) {
0408         QTextCursor cursor = textCursor();
0409         QTextBlock block = cursor.block();
0410         if (cursor.position() == block.position() + block.length() - 2) {
0411             cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor);
0412         } else {
0413             cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
0414         }
0415         cursor.removeSelectedText();
0416         setTextCursor(cursor);
0417         return true;
0418     }
0419 
0420     return false;
0421 }
0422 
0423 bool RichTextBrowser::overrideShortcut(QKeyEvent *event)
0424 {
0425     const int key = event->key() | event->modifiers();
0426 
0427     if (KStandardShortcut::copy().contains(key)) {
0428         return true;
0429     } else if (KStandardShortcut::paste().contains(key)) {
0430         return true;
0431     } else if (KStandardShortcut::cut().contains(key)) {
0432         return true;
0433     } else if (KStandardShortcut::undo().contains(key)) {
0434         return true;
0435     } else if (KStandardShortcut::redo().contains(key)) {
0436         return true;
0437     } else if (KStandardShortcut::deleteWordBack().contains(key)) {
0438         return true;
0439     } else if (KStandardShortcut::deleteWordForward().contains(key)) {
0440         return true;
0441     } else if (KStandardShortcut::backwardWord().contains(key)) {
0442         return true;
0443     } else if (KStandardShortcut::forwardWord().contains(key)) {
0444         return true;
0445     } else if (KStandardShortcut::next().contains(key)) {
0446         return true;
0447     } else if (KStandardShortcut::prior().contains(key)) {
0448         return true;
0449     } else if (KStandardShortcut::begin().contains(key)) {
0450         return true;
0451     } else if (KStandardShortcut::end().contains(key)) {
0452         return true;
0453     } else if (KStandardShortcut::beginningOfLine().contains(key)) {
0454         return true;
0455     } else if (KStandardShortcut::endOfLine().contains(key)) {
0456         return true;
0457     } else if (KStandardShortcut::pasteSelection().contains(key)) {
0458         return true;
0459     } else if (searchSupport() && KStandardShortcut::find().contains(key)) {
0460         return true;
0461     } else if (searchSupport() && KStandardShortcut::findNext().contains(key)) {
0462         return true;
0463     } else if (event->matches(QKeySequence::SelectAll)) { // currently missing in QTextEdit
0464         return true;
0465     } else if (event == QKeySequence::DeleteEndOfLine) {
0466         return true;
0467     }
0468     return false;
0469 }
0470 
0471 void RichTextBrowser::keyPressEvent(QKeyEvent *event)
0472 {
0473     const bool isControlClicked = event->modifiers() & Qt::ControlModifier;
0474     const bool isShiftClicked = event->modifiers() & Qt::ShiftModifier;
0475     if (handleShortcut(event)) {
0476         event->accept();
0477     } else if (event->key() == Qt::Key_Up && isControlClicked && isShiftClicked) {
0478         moveLineUpDown(true);
0479         event->accept();
0480     } else if (event->key() == Qt::Key_Down && isControlClicked && isShiftClicked) {
0481         moveLineUpDown(false);
0482         event->accept();
0483     } else if (event->key() == Qt::Key_Up && isControlClicked) {
0484         moveCursorBeginUpDown(true);
0485         event->accept();
0486     } else if (event->key() == Qt::Key_Down && isControlClicked) {
0487         moveCursorBeginUpDown(false);
0488         event->accept();
0489     } else {
0490         QTextEdit::keyPressEvent(event);
0491     }
0492 }
0493 
0494 int RichTextBrowser::zoomFactor() const
0495 {
0496     int pourcentage = 100;
0497     const QFont f = font();
0498     if (d->mInitialFontSize != f.pointSize()) {
0499         pourcentage = (f.pointSize() * 100) / d->mInitialFontSize;
0500     }
0501     return pourcentage;
0502 }
0503 
0504 void RichTextBrowser::slotZoomReset()
0505 {
0506     QFont f = font();
0507     if (d->mInitialFontSize != f.pointSize()) {
0508         f.setPointSize(d->mInitialFontSize);
0509         setFont(f);
0510     }
0511 }
0512 
0513 void RichTextBrowser::moveCursorBeginUpDown(bool moveUp)
0514 {
0515     QTextCursor cursor = textCursor();
0516     QTextCursor move = cursor;
0517     move.beginEditBlock();
0518     cursor.clearSelection();
0519     move.movePosition(QTextCursor::StartOfBlock);
0520     move.movePosition(moveUp ? QTextCursor::PreviousBlock : QTextCursor::NextBlock);
0521     move.endEditBlock();
0522     setTextCursor(move);
0523 }
0524 
0525 void RichTextBrowser::moveLineUpDown(bool moveUp)
0526 {
0527     QTextCursor cursor = textCursor();
0528     QTextCursor move = cursor;
0529     move.beginEditBlock();
0530 
0531     const bool hasSelection = cursor.hasSelection();
0532 
0533     if (hasSelection) {
0534         move.setPosition(cursor.selectionStart());
0535         move.movePosition(QTextCursor::StartOfBlock);
0536         move.setPosition(cursor.selectionEnd(), QTextCursor::KeepAnchor);
0537         move.movePosition(move.atBlockStart() ? QTextCursor::Left : QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
0538     } else {
0539         move.movePosition(QTextCursor::StartOfBlock);
0540         move.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
0541     }
0542     const QString text = move.selectedText();
0543 
0544     move.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor);
0545     move.removeSelectedText();
0546 
0547     if (moveUp) {
0548         move.movePosition(QTextCursor::PreviousBlock);
0549         move.insertBlock();
0550         move.movePosition(QTextCursor::Left);
0551     } else {
0552         move.movePosition(QTextCursor::EndOfBlock);
0553         if (move.atBlockStart()) { // empty block
0554             move.movePosition(QTextCursor::NextBlock);
0555             move.insertBlock();
0556             move.movePosition(QTextCursor::Left);
0557         } else {
0558             move.insertBlock();
0559         }
0560     }
0561 
0562     int start = move.position();
0563     move.clearSelection();
0564     move.insertText(text);
0565     int end = move.position();
0566 
0567     if (hasSelection) {
0568         move.setPosition(end);
0569         move.setPosition(start, QTextCursor::KeepAnchor);
0570     } else {
0571         move.setPosition(start);
0572     }
0573     move.endEditBlock();
0574 
0575     setTextCursor(move);
0576 }
0577 
0578 #include "moc_richtextbrowser.cpp"