File indexing completed on 2024-05-19 05:21:43

0001 /*
0002    SPDX-FileCopyrightText: 2015-2024 Laurent Montel <montel@kde.org>
0003 
0004    SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "richtextcomposer.h"
0008 #include "grantleebuilder/markupdirector.h"
0009 #include "grantleebuilder/plaintextmarkupbuilder.h"
0010 #include "nestedlisthelper_p.h"
0011 #include "richtextcomposeractions.h"
0012 #include "richtextcomposercontroler.h"
0013 #include "richtextcomposeremailquotehighlighter.h"
0014 #include "richtextcomposerimages.h"
0015 #include "richtextexternalcomposer.h"
0016 #include <QClipboard>
0017 #include <QTextBlock>
0018 #include <QTextLayout>
0019 
0020 #include "richtextcomposeremailquotedecorator.h"
0021 
0022 #include <KActionCollection>
0023 #include <QAction>
0024 #include <QFileInfo>
0025 #include <QMimeData>
0026 
0027 using namespace KPIMTextEdit;
0028 
0029 class Q_DECL_HIDDEN RichTextComposer::RichTextComposerPrivate
0030 {
0031 public:
0032     RichTextComposerPrivate(RichTextComposer *qq)
0033         : q(qq)
0034     {
0035         composerControler = new RichTextComposerControler(q, q);
0036         richTextComposerActions = new RichTextComposerActions(composerControler, q);
0037         externalComposer = new KPIMTextEdit::RichTextExternalComposer(q, q);
0038         q->connect(externalComposer, &RichTextExternalComposer::externalEditorClosed, qq, &RichTextComposer::externalEditorClosed);
0039         q->connect(externalComposer, &RichTextExternalComposer::externalEditorStarted, qq, &RichTextComposer::externalEditorStarted);
0040         q->connect(q, &RichTextComposer::textModeChanged, q, &RichTextComposer::slotTextModeChanged);
0041     }
0042 
0043     QString quotePrefix;
0044     RichTextComposerControler *composerControler = nullptr;
0045     RichTextComposerActions *richTextComposerActions = nullptr;
0046     KPIMTextEdit::RichTextExternalComposer *externalComposer = nullptr;
0047     RichTextComposer *const q;
0048     RichTextComposer::Mode mode = RichTextComposer::Plain;
0049     bool forcePlainTextMarkup = false;
0050     struct UndoHtmlVersion {
0051         QString originalHtml;
0052         QString plainText;
0053         [[nodiscard]] bool isValid() const
0054         {
0055             return !originalHtml.isEmpty() && !plainText.isEmpty();
0056         }
0057 
0058         void clear()
0059         {
0060             originalHtml.clear();
0061             plainText.clear();
0062         }
0063     };
0064     UndoHtmlVersion undoHtmlVersion;
0065     bool blockClearUndoHtmlVersion = false;
0066     QMetaObject::Connection mRichTextChangedConnection;
0067 };
0068 
0069 RichTextComposer::RichTextComposer(QWidget *parent)
0070     : TextCustomEditor::RichTextEditor(parent)
0071     , d(new RichTextComposerPrivate(this))
0072 {
0073     setAcceptRichText(false);
0074     d->mRichTextChangedConnection = connect(this, &RichTextComposer::textChanged, this, [this]() {
0075         if (!d->blockClearUndoHtmlVersion && d->undoHtmlVersion.isValid() && (d->mode == RichTextComposer::Plain)) {
0076             if (toPlainText() != d->undoHtmlVersion.plainText) {
0077                 d->undoHtmlVersion.clear();
0078             }
0079         }
0080     });
0081 }
0082 
0083 RichTextComposer::~RichTextComposer()
0084 {
0085     disconnect(d->mRichTextChangedConnection);
0086 }
0087 
0088 KPIMTextEdit::RichTextExternalComposer *RichTextComposer::externalComposer() const
0089 {
0090     return d->externalComposer;
0091 }
0092 
0093 KPIMTextEdit::RichTextComposerControler *RichTextComposer::composerControler() const
0094 {
0095     return d->composerControler;
0096 }
0097 
0098 KPIMTextEdit::RichTextComposerActions *RichTextComposer::composerActions() const
0099 {
0100     return d->richTextComposerActions;
0101 }
0102 
0103 QList<QAction *> RichTextComposer::richTextActionList() const
0104 {
0105     return d->richTextComposerActions->richTextActionList();
0106 }
0107 
0108 void RichTextComposer::setEnableActions(bool state)
0109 {
0110     d->richTextComposerActions->setActionsEnabled(state);
0111 }
0112 
0113 void RichTextComposer::createActions(KActionCollection *ac)
0114 {
0115     d->richTextComposerActions->createActions(ac);
0116 }
0117 
0118 void RichTextComposer::updateHighLighter()
0119 {
0120     auto hlighter = qobject_cast<KPIMTextEdit::RichTextComposerEmailQuoteHighlighter *>(highlighter());
0121     if (hlighter) {
0122         hlighter->toggleSpellHighlighting(checkSpellingEnabled());
0123     }
0124 }
0125 
0126 void RichTextComposer::clearDecorator()
0127 {
0128     // Nothing
0129 }
0130 
0131 void RichTextComposer::createHighlighter()
0132 {
0133     auto highlighter = new KPIMTextEdit::RichTextComposerEmailQuoteHighlighter(this);
0134     highlighter->toggleSpellHighlighting(checkSpellingEnabled());
0135     setHighlighterColors(highlighter);
0136     setHighlighter(highlighter);
0137 }
0138 
0139 void RichTextComposer::setHighlighterColors(KPIMTextEdit::RichTextComposerEmailQuoteHighlighter *highlighter)
0140 {
0141     Q_UNUSED(highlighter)
0142 }
0143 
0144 void RichTextComposer::setUseExternalEditor(bool use)
0145 {
0146     d->externalComposer->setUseExternalEditor(use);
0147 }
0148 
0149 void RichTextComposer::setExternalEditorPath(const QString &path)
0150 {
0151     d->externalComposer->setExternalEditorPath(path);
0152 }
0153 
0154 bool RichTextComposer::checkExternalEditorFinished()
0155 {
0156     return d->externalComposer->checkExternalEditorFinished();
0157 }
0158 
0159 void RichTextComposer::killExternalEditor()
0160 {
0161     d->externalComposer->killExternalEditor();
0162 }
0163 
0164 RichTextComposer::Mode RichTextComposer::textMode() const
0165 {
0166     return d->mode;
0167 }
0168 
0169 void RichTextComposer::enableWordWrap(int wrapColumn)
0170 {
0171     setWordWrapMode(QTextOption::WordWrap);
0172     setLineWrapMode(QTextEdit::FixedColumnWidth);
0173     setLineWrapColumnOrWidth(wrapColumn);
0174 }
0175 
0176 void RichTextComposer::disableWordWrap()
0177 {
0178     setLineWrapMode(QTextEdit::WidgetWidth);
0179 }
0180 
0181 int RichTextComposer::linePosition() const
0182 {
0183     const QTextCursor cursor = textCursor();
0184     const QTextDocument *doc = document();
0185     QTextBlock block = doc->begin();
0186     int lineCount = 0;
0187 
0188     // Simply using cursor.block.blockNumber() would not work since that does not
0189     // take word-wrapping into account, i.e. it is possible to have more than one
0190     // line in a block.
0191     //
0192     // What we have to do therefore is to iterate over the blocks and count the
0193     // lines in them. Once we have reached the block where the cursor is, we have
0194     // to iterate over each line in it, to find the exact line in the block where
0195     // the cursor is.
0196     while (block.isValid()) {
0197         const QTextLayout *layout = block.layout();
0198 
0199         // If the current block has the cursor in it, iterate over all its lines
0200         if (block == cursor.block()) {
0201             // Special case: Cursor at end of single non-wrapped line, exit early
0202             // in this case as the logic below can't handle it
0203             if (block.lineCount() == layout->lineCount()) {
0204                 return lineCount;
0205             }
0206 
0207             const int cursorBasePosition = cursor.position() - block.position();
0208             const int numberOfLine(layout->lineCount());
0209             for (int i = 0; i < numberOfLine; ++i) {
0210                 QTextLine line = layout->lineAt(i);
0211                 if (cursorBasePosition >= line.textStart() && cursorBasePosition < line.textStart() + line.textLength()) {
0212                     break;
0213                 }
0214                 lineCount++;
0215             }
0216             return lineCount;
0217         } else {
0218             // No, cursor is not in the current block
0219             lineCount += layout->lineCount();
0220         }
0221 
0222         block = block.next();
0223     }
0224 
0225     // Only gets here if the cursor block can't be found, shouldn't happen except
0226     // for an empty document maybe
0227     return lineCount;
0228 }
0229 
0230 int RichTextComposer::columnNumber() const
0231 {
0232     const QTextCursor cursor = textCursor();
0233     return cursor.columnNumber();
0234 }
0235 
0236 void RichTextComposer::forcePlainTextMarkup(bool force)
0237 {
0238     d->forcePlainTextMarkup = force;
0239 }
0240 
0241 void RichTextComposer::insertPlainTextImplementation()
0242 {
0243     if (d->forcePlainTextMarkup) {
0244         auto pb = new KPIMTextEdit::PlainTextMarkupBuilder();
0245         pb->setQuotePrefix(defaultQuoteSign());
0246         auto pmd = new KPIMTextEdit::MarkupDirector(pb);
0247         pmd->processDocument(document());
0248         const QString plainText = pb->getResult();
0249         document()->setPlainText(plainText);
0250         delete pmd;
0251         delete pb;
0252     } else {
0253         document()->setPlainText(document()->toPlainText());
0254     }
0255 }
0256 
0257 void RichTextComposer::slotChangeInsertMode()
0258 {
0259     setOverwriteMode(!overwriteMode());
0260     Q_EMIT insertModeChanged();
0261 }
0262 
0263 void RichTextComposer::activateRichText()
0264 {
0265     if (d->mode == RichTextComposer::Plain) {
0266         setAcceptRichText(true);
0267         d->mode = RichTextComposer::Rich;
0268         if (d->undoHtmlVersion.isValid() && (toPlainText() == d->undoHtmlVersion.plainText)) {
0269             setHtml(d->undoHtmlVersion.originalHtml);
0270             d->undoHtmlVersion.clear();
0271 #if 0 // Need to investigate it
0272         } else {
0273             //try to import markdown
0274             document()->setMarkdown(toPlainText(), QTextDocument::MarkdownDialectCommonMark);
0275 #endif
0276         }
0277         Q_EMIT textModeChanged(d->mode);
0278     }
0279 }
0280 
0281 void RichTextComposer::switchToPlainText()
0282 {
0283     if (d->mode == RichTextComposer::Rich) {
0284         d->mode = RichTextComposer::Plain;
0285         d->blockClearUndoHtmlVersion = true;
0286         d->undoHtmlVersion.originalHtml = toHtml();
0287         // TODO: Warn the user about this?
0288         insertPlainTextImplementation();
0289         setAcceptRichText(false);
0290         d->undoHtmlVersion.plainText = toPlainText();
0291         d->blockClearUndoHtmlVersion = false;
0292         Q_EMIT textModeChanged(d->mode);
0293     }
0294 }
0295 
0296 QString RichTextComposer::textOrHtml() const
0297 {
0298     if (textMode() == Rich) {
0299         return d->composerControler->toCleanHtml();
0300     } else {
0301         return toPlainText();
0302     }
0303 }
0304 
0305 void RichTextComposer::setTextOrHtml(const QString &text)
0306 {
0307     // might be rich text
0308     if (Qt::mightBeRichText(text)) {
0309         if (d->mode == RichTextComposer::Plain) {
0310             activateRichText();
0311         }
0312         setHtml(text);
0313     } else {
0314         setPlainText(text);
0315     }
0316 }
0317 
0318 void RichTextComposer::evaluateReturnKeySupport(QKeyEvent *event)
0319 {
0320     if (event->key() == Qt::Key_Return) {
0321         QTextCursor cursor = textCursor();
0322         const int oldPos = cursor.position();
0323         const int blockPos = cursor.block().position();
0324 
0325         // selection all the line.
0326         cursor.movePosition(QTextCursor::StartOfBlock);
0327         cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
0328         QString lineText = cursor.selectedText();
0329         if (((oldPos - blockPos) > 0) && ((oldPos - blockPos) < int(lineText.length()))) {
0330             bool isQuotedLine = false;
0331             int bot = 0; // bot = begin of text after quote indicators
0332             while (bot < lineText.length()) {
0333                 if ((lineText[bot] == QChar::fromLatin1('>')) || (lineText[bot] == QChar::fromLatin1('|'))) {
0334                     isQuotedLine = true;
0335                     ++bot;
0336                 } else if (lineText[bot].isSpace()) {
0337                     ++bot;
0338                 } else {
0339                     break;
0340                 }
0341             }
0342             evaluateListSupport(event);
0343             // duplicate quote indicators of the previous line before the new
0344             // line if the line actually contained text (apart from the quote
0345             // indicators) and the cursor is behind the quote indicators
0346             if (isQuotedLine && (bot != lineText.length()) && ((oldPos - blockPos) >= int(bot))) {
0347                 // The cursor position might have changed unpredictably if there was selected
0348                 // text which got replaced by a new line, so we query it again:
0349                 cursor.movePosition(QTextCursor::StartOfBlock);
0350                 cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
0351                 QString newLine = cursor.selectedText();
0352 
0353                 // remove leading white space from the new line and instead
0354                 // add the quote indicators of the previous line
0355                 int leadingWhiteSpaceCount = 0;
0356                 while ((leadingWhiteSpaceCount < newLine.length()) && newLine[leadingWhiteSpaceCount].isSpace()) {
0357                     ++leadingWhiteSpaceCount;
0358                 }
0359                 newLine.replace(0, leadingWhiteSpaceCount, lineText.left(bot));
0360                 cursor.insertText(newLine);
0361                 // cursor.setPosition( cursor.position() + 2 );
0362                 cursor.movePosition(QTextCursor::StartOfBlock);
0363                 setTextCursor(cursor);
0364             }
0365         } else {
0366             evaluateListSupport(event);
0367         }
0368     } else {
0369         evaluateListSupport(event);
0370     }
0371 }
0372 
0373 void RichTextComposer::evaluateListSupport(QKeyEvent *event)
0374 {
0375     bool handled = false;
0376     if (textCursor().currentList()) {
0377         // handled is False if the key press event was not handled or not completely
0378         // handled by the Helper class.
0379         handled = d->composerControler->nestedListHelper()->handleBeforeKeyPressEvent(event);
0380     }
0381 
0382     // If a line was merged with previous (next) one, with different heading level,
0383     // the style should also be adjusted accordingly (i.e. merged)
0384     if ((event->key() == Qt::Key_Backspace && textCursor().atBlockStart()
0385          && (textCursor().blockFormat().headingLevel() != textCursor().block().previous().blockFormat().headingLevel()))
0386         || (event->key() == Qt::Key_Delete && textCursor().atBlockEnd()
0387             && (textCursor().blockFormat().headingLevel() != textCursor().block().next().blockFormat().headingLevel()))) {
0388         QTextCursor cursor = textCursor();
0389         cursor.beginEditBlock();
0390         if (event->key() == Qt::Key_Delete) {
0391             cursor.deleteChar();
0392         } else {
0393             cursor.deletePreviousChar();
0394         }
0395         d->composerControler->setHeadingLevel(cursor.blockFormat().headingLevel());
0396         cursor.endEditBlock();
0397         handled = true;
0398     }
0399 
0400     if (!handled) {
0401         TextCustomEditor::RichTextEditor::keyPressEvent(event);
0402     }
0403 
0404     // Match the behavior of office suites: newline after header switches to normal text
0405     if ((event->key() == Qt::Key_Return) && (textCursor().blockFormat().headingLevel() > 0) && (textCursor().atBlockEnd())) {
0406         // it should be undoable together with actual "return" keypress
0407         textCursor().joinPreviousEditBlock();
0408         d->composerControler->setHeadingLevel(0);
0409         textCursor().endEditBlock();
0410     }
0411 
0412     if (textCursor().currentList()) {
0413         d->composerControler->nestedListHelper()->handleAfterKeyPressEvent(event);
0414     }
0415     Q_EMIT cursorPositionChanged();
0416 }
0417 
0418 bool RichTextComposer::processKeyEvent(QKeyEvent *e)
0419 {
0420     if (d->externalComposer->useExternalEditor() && (e->key() != Qt::Key_Shift) && (e->key() != Qt::Key_Control) && (e->key() != Qt::Key_Meta)
0421         && (e->key() != Qt::Key_CapsLock) && (e->key() != Qt::Key_NumLock) && (e->key() != Qt::Key_ScrollLock) && (e->key() != Qt::Key_Alt)
0422         && (e->key() != Qt::Key_AltGr)) {
0423         if (!d->externalComposer->isInProgress()) {
0424             d->externalComposer->startExternalEditor();
0425         }
0426         return true;
0427     }
0428 
0429     if (e->key() == Qt::Key_Up && e->modifiers() != Qt::ShiftModifier && textCursor().block().position() == 0
0430         && textCursor().block().layout()->lineForTextPosition(textCursor().position()).lineNumber() == 0) {
0431         textCursor().clearSelection();
0432         Q_EMIT focusUp();
0433     } else if (e->key() == Qt::Key_Backtab && e->modifiers() == Qt::ShiftModifier) {
0434         textCursor().clearSelection();
0435         Q_EMIT focusUp();
0436     } else {
0437         if (!processModifyText(e)) {
0438             evaluateReturnKeySupport(e);
0439         }
0440     }
0441     return true;
0442 }
0443 
0444 bool RichTextComposer::processModifyText(QKeyEvent *event)
0445 {
0446     Q_UNUSED(event)
0447     return false;
0448 }
0449 
0450 void RichTextComposer::keyPressEvent(QKeyEvent *e)
0451 {
0452     processKeyEvent(e);
0453 }
0454 
0455 Sonnet::SpellCheckDecorator *RichTextComposer::createSpellCheckDecorator()
0456 {
0457     return new KPIMTextEdit::RichTextComposerEmailQuoteDecorator(this);
0458 }
0459 
0460 QString RichTextComposer::smartQuote(const QString &msg)
0461 {
0462     return msg;
0463 }
0464 
0465 void RichTextComposer::setQuotePrefixName(const QString &quotePrefix)
0466 {
0467     d->quotePrefix = quotePrefix;
0468 }
0469 
0470 QString RichTextComposer::quotePrefixName() const
0471 {
0472     if (!d->quotePrefix.simplified().isEmpty()) {
0473         return d->quotePrefix;
0474     } else {
0475         return QStringLiteral(">");
0476     }
0477 }
0478 
0479 int RichTextComposer::quoteLength(const QString &line, bool oneQuote) const
0480 {
0481     if (!d->quotePrefix.simplified().isEmpty()) {
0482         if (line.startsWith(d->quotePrefix)) {
0483             return d->quotePrefix.length();
0484         } else {
0485             return 0;
0486         }
0487     } else {
0488         bool quoteFound = false;
0489         int startOfText = -1;
0490         const int lineLength(line.length());
0491         for (int i = 0; i < lineLength; ++i) {
0492             if (line[i] == QLatin1Char('>') || line[i] == QLatin1Char('|')) {
0493                 if (quoteFound && oneQuote) {
0494                     break;
0495                 }
0496                 quoteFound = true;
0497             } else if (line[i] != QLatin1Char(' ')) {
0498                 startOfText = i;
0499                 break;
0500             }
0501         }
0502         if (quoteFound) {
0503             // We found a quote but it's just quote element => 1 => remove 1 char.
0504             if (startOfText == -1) {
0505                 startOfText = 1;
0506             }
0507             return startOfText;
0508         } else {
0509             return 0;
0510         }
0511     }
0512 }
0513 
0514 void RichTextComposer::setCursorPositionFromStart(unsigned int pos)
0515 {
0516     d->composerControler->setCursorPositionFromStart(pos);
0517 }
0518 
0519 bool RichTextComposer::isLineQuoted(const QString &line) const
0520 {
0521     return quoteLength(line) > 0;
0522 }
0523 
0524 const QString RichTextComposer::defaultQuoteSign() const
0525 {
0526     if (!d->quotePrefix.simplified().isEmpty()) {
0527         return d->quotePrefix;
0528     } else {
0529         return QStringLiteral("> ");
0530     }
0531 }
0532 
0533 void RichTextComposer::insertFromMimeData(const QMimeData *source)
0534 {
0535     // Add an image if that is on the clipboard
0536     if (textMode() == RichTextComposer::Rich && source->hasImage()) {
0537         const auto image = qvariant_cast<QImage>(source->imageData());
0538         QFileInfo fi;
0539         d->composerControler->composerImages()->insertImage(image, fi);
0540         return;
0541     }
0542 
0543     // Attempt to paste HTML contents into the text edit in plain text mode,
0544     // prevent this and prevent plain text instead.
0545     if (textMode() == RichTextComposer::Plain && source->hasHtml()) {
0546         if (source->hasText()) {
0547             insertPlainText(source->text());
0548             return;
0549         }
0550     }
0551 
0552     if (textMode() == RichTextComposer::Rich) {
0553         if (source->hasText()) {
0554             const QString sourceText = source->text();
0555             if (sourceText.startsWith(QLatin1StringView("http://")) || sourceText.startsWith(QLatin1StringView("https://"))
0556                 || sourceText.startsWith(QLatin1StringView("ftps://")) || sourceText.startsWith(QLatin1StringView("ftp://"))
0557                 || sourceText.startsWith(QLatin1StringView("mailto:")) || sourceText.startsWith(QLatin1StringView("smb://"))
0558                 || sourceText.startsWith(QLatin1StringView("file://")) || sourceText.startsWith(QLatin1StringView("webdavs://"))
0559                 || sourceText.startsWith(QLatin1StringView("imaps://")) || sourceText.startsWith(QLatin1StringView("sftp://"))
0560                 || sourceText.startsWith(QLatin1StringView("fish://")) || sourceText.startsWith(QLatin1StringView("tel:"))) {
0561                 insertHtml(QStringLiteral("<a href=\"%1\">%1</a>").arg(sourceText));
0562                 return;
0563             }
0564         }
0565     }
0566 
0567     TextCustomEditor::RichTextEditor::insertFromMimeData(source);
0568 }
0569 
0570 bool RichTextComposer::canInsertFromMimeData(const QMimeData *source) const
0571 {
0572     if (source->hasHtml() && textMode() == RichTextComposer::Rich) {
0573         return true;
0574     }
0575 
0576     if (source->hasText()) {
0577         return true;
0578     }
0579 
0580     if (textMode() == RichTextComposer::Rich && source->hasImage()) {
0581         return true;
0582     }
0583 
0584     return TextCustomEditor::RichTextEditor::canInsertFromMimeData(source);
0585 }
0586 
0587 void RichTextComposer::mouseReleaseEvent(QMouseEvent *event)
0588 {
0589     if (d->composerControler->painterActive()) {
0590         d->composerControler->disablePainter();
0591         d->richTextComposerActions->uncheckActionFormatPainter();
0592     }
0593     TextCustomEditor::RichTextEditor::mouseReleaseEvent(event);
0594 }
0595 
0596 void RichTextComposer::slotTextModeChanged(KPIMTextEdit::RichTextComposer::Mode mode)
0597 {
0598     d->composerControler->textModeChanged(mode);
0599     d->richTextComposerActions->textModeChanged(mode);
0600 }
0601 
0602 #include "moc_richtextcomposer.cpp"