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 "ePrefix) 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"