KPimTextEdit

richtextcomposer.cpp
1/*
2 SPDX-FileCopyrightText: 2015-2024 Laurent Montel <montel@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7#include "richtextcomposer.h"
8#include "grantleebuilder/markupdirector.h"
9#include "grantleebuilder/plaintextmarkupbuilder.h"
10#include "nestedlisthelper_p.h"
11#include "richtextcomposeractions.h"
12#include "richtextcomposercontroler.h"
13#include "richtextcomposeremailquotehighlighter.h"
14#include "richtextcomposerimages.h"
15#include "richtextexternalcomposer.h"
16#include <QClipboard>
17#include <QTextBlock>
18#include <QTextLayout>
19
20#include "richtextcomposeremailquotedecorator.h"
21
22#include <KActionCollection>
23#include <QAction>
24#include <QFileInfo>
25#include <QMimeData>
26
27using namespace KPIMTextEdit;
28using namespace Qt::Literals::StringLiterals;
29
30class Q_DECL_HIDDEN RichTextComposer::RichTextComposerPrivate
31{
32public:
33 RichTextComposerPrivate(RichTextComposer *qq)
34 : q(qq)
35 {
36 composerControler = new RichTextComposerControler(q, q);
37 richTextComposerActions = new RichTextComposerActions(composerControler, q);
38 externalComposer = new KPIMTextEdit::RichTextExternalComposer(q, q);
39 q->connect(externalComposer, &RichTextExternalComposer::externalEditorClosed, qq, &RichTextComposer::externalEditorClosed);
40 q->connect(externalComposer, &RichTextExternalComposer::externalEditorStarted, qq, &RichTextComposer::externalEditorStarted);
41 q->connect(q, &RichTextComposer::textModeChanged, q, &RichTextComposer::slotTextModeChanged);
42 }
43
44 QString quotePrefix;
45 RichTextComposerControler *composerControler = nullptr;
46 RichTextComposerActions *richTextComposerActions = nullptr;
47 KPIMTextEdit::RichTextExternalComposer *externalComposer = nullptr;
48 RichTextComposer *const q;
50 bool forcePlainTextMarkup = false;
51 struct UndoHtmlVersion {
52 QString originalHtml;
53 QString plainText;
54 [[nodiscard]] bool isValid() const
55 {
56 return !originalHtml.isEmpty() && !plainText.isEmpty();
57 }
58
59 void clear()
60 {
61 originalHtml.clear();
62 plainText.clear();
63 }
64 };
65 UndoHtmlVersion undoHtmlVersion;
66 bool blockClearUndoHtmlVersion = false;
67 QMetaObject::Connection mRichTextChangedConnection;
68};
69
70RichTextComposer::RichTextComposer(QWidget *parent)
71 : TextCustomEditor::RichTextEditor(parent)
72 , d(new RichTextComposerPrivate(this))
73{
74 setAcceptRichText(false);
75 d->mRichTextChangedConnection = connect(this, &RichTextComposer::textChanged, this, [this]() {
76 if (!d->blockClearUndoHtmlVersion && d->undoHtmlVersion.isValid() && (d->mode == RichTextComposer::Plain)) {
77 if (toPlainText() != d->undoHtmlVersion.plainText) {
78 d->undoHtmlVersion.clear();
79 }
80 }
81 });
82}
83
84RichTextComposer::~RichTextComposer()
85{
86 disconnect(d->mRichTextChangedConnection);
87}
88
89KPIMTextEdit::RichTextExternalComposer *RichTextComposer::externalComposer() const
90{
91 return d->externalComposer;
92}
93
94KPIMTextEdit::RichTextComposerControler *RichTextComposer::composerControler() const
95{
96 return d->composerControler;
97}
98
99KPIMTextEdit::RichTextComposerActions *RichTextComposer::composerActions() const
100{
101 return d->richTextComposerActions;
102}
103
104QList<QAction *> RichTextComposer::richTextActionList() const
105{
106 return d->richTextComposerActions->richTextActionList();
107}
108
109void RichTextComposer::setEnableActions(bool state)
110{
111 d->richTextComposerActions->setActionsEnabled(state);
112}
113
114void RichTextComposer::createActions(KActionCollection *ac)
115{
116 d->richTextComposerActions->createActions(ac);
117}
118
119void RichTextComposer::updateHighLighter()
120{
122 if (hlighter) {
123 hlighter->toggleSpellHighlighting(checkSpellingEnabled());
124 }
125}
126
127void RichTextComposer::clearDecorator()
128{
129 // Nothing
130}
131
132void RichTextComposer::createHighlighter()
133{
134 auto highlighter = new KPIMTextEdit::RichTextComposerEmailQuoteHighlighter(this);
135 highlighter->toggleSpellHighlighting(checkSpellingEnabled());
136 setHighlighterColors(highlighter);
137 setHighlighter(highlighter);
138}
139
140void RichTextComposer::setHighlighterColors(KPIMTextEdit::RichTextComposerEmailQuoteHighlighter *highlighter)
141{
142 Q_UNUSED(highlighter)
143}
144
145void RichTextComposer::setUseExternalEditor(bool use)
146{
147 d->externalComposer->setUseExternalEditor(use);
148}
149
150void RichTextComposer::setExternalEditorPath(const QString &path)
151{
152 d->externalComposer->setExternalEditorPath(path);
153}
154
155bool RichTextComposer::checkExternalEditorFinished()
156{
157 return d->externalComposer->checkExternalEditorFinished();
158}
159
160void RichTextComposer::killExternalEditor()
161{
162 d->externalComposer->killExternalEditor();
163}
164
166{
167 return d->mode;
168}
169
176
181
183{
184 const QTextCursor cursor = textCursor();
185 const QTextDocument *doc = document();
186 QTextBlock block = doc->begin();
187 int lineCount = 0;
188
189 // Simply using cursor.block.blockNumber() would not work since that does not
190 // take word-wrapping into account, i.e. it is possible to have more than one
191 // line in a block.
192 //
193 // What we have to do therefore is to iterate over the blocks and count the
194 // lines in them. Once we have reached the block where the cursor is, we have
195 // to iterate over each line in it, to find the exact line in the block where
196 // the cursor is.
197 while (block.isValid()) {
198 const QTextLayout *layout = block.layout();
199
200 // If the current block has the cursor in it, iterate over all its lines
201 if (block == cursor.block()) {
202 // Special case: Cursor at end of single non-wrapped line, exit early
203 // in this case as the logic below can't handle it
204 if (block.lineCount() == layout->lineCount()) {
205 return lineCount;
206 }
207
208 const int cursorBasePosition = cursor.position() - block.position();
209 const int numberOfLine(layout->lineCount());
210 for (int i = 0; i < numberOfLine; ++i) {
211 QTextLine line = layout->lineAt(i);
212 if (cursorBasePosition >= line.textStart() && cursorBasePosition < line.textStart() + line.textLength()) {
213 break;
214 }
215 lineCount++;
216 }
217 return lineCount;
218 } else {
219 // No, cursor is not in the current block
220 lineCount += layout->lineCount();
221 }
222
223 block = block.next();
224 }
225
226 // Only gets here if the cursor block can't be found, shouldn't happen except
227 // for an empty document maybe
228 return lineCount;
229}
230
232{
233 const QTextCursor cursor = textCursor();
234 return cursor.columnNumber();
235}
236
237void RichTextComposer::forcePlainTextMarkup(bool force)
238{
239 d->forcePlainTextMarkup = force;
240}
241
242void RichTextComposer::insertPlainTextImplementation()
243{
244 if (d->forcePlainTextMarkup) {
245 auto pb = new KPIMTextEdit::PlainTextMarkupBuilder();
246 pb->setQuotePrefix(defaultQuoteSign());
247 auto pmd = new KPIMTextEdit::MarkupDirector(pb);
248 pmd->processDocument(document());
249 const QString plainText = pb->getResult();
250 document()->setPlainText(plainText);
251 delete pmd;
252 delete pb;
253 } else {
254 document()->setPlainText(document()->toPlainText());
255 }
256}
257
258void RichTextComposer::slotChangeInsertMode()
259{
261 Q_EMIT insertModeChanged();
262}
263
264void RichTextComposer::activateRichText()
265{
266 if (d->mode == RichTextComposer::Plain) {
267 setAcceptRichText(true);
268 d->mode = RichTextComposer::Rich;
269 if (d->undoHtmlVersion.isValid() && (toPlainText() == d->undoHtmlVersion.plainText)) {
270 setHtml(d->undoHtmlVersion.originalHtml);
271 d->undoHtmlVersion.clear();
272#if 0 // Need to investigate it
273 } else {
274 //try to import markdown
275 document()->setMarkdown(toPlainText(), QTextDocument::MarkdownDialectCommonMark);
276#endif
277 }
278 Q_EMIT textModeChanged(d->mode);
279 }
280}
281
282void RichTextComposer::switchToPlainText()
283{
284 if (d->mode == RichTextComposer::Rich) {
285 d->mode = RichTextComposer::Plain;
286 d->blockClearUndoHtmlVersion = true;
287 d->undoHtmlVersion.originalHtml = toHtml();
288 // TODO: Warn the user about this?
289 insertPlainTextImplementation();
290 setAcceptRichText(false);
291 d->undoHtmlVersion.plainText = toPlainText();
292 d->blockClearUndoHtmlVersion = false;
293 Q_EMIT textModeChanged(d->mode);
294 }
295}
296
297QString RichTextComposer::textOrHtml() const
298{
299 if (textMode() == Rich) {
300 return d->composerControler->toCleanHtml();
301 } else {
302 return toPlainText();
303 }
304}
305
306void RichTextComposer::setTextOrHtml(const QString &text)
307{
308 // might be rich text
309 if (Qt::mightBeRichText(text)) {
310 if (d->mode == RichTextComposer::Plain) {
311 activateRichText();
312 }
313 setHtml(text);
314 } else {
315 setPlainText(text);
316 }
317}
318
319void RichTextComposer::evaluateReturnKeySupport(QKeyEvent *event)
320{
321 if (event->key() == Qt::Key_Return) {
323 const int oldPos = cursor.position();
324 const int blockPos = cursor.block().position();
325
326 // selection all the line.
327 cursor.movePosition(QTextCursor::StartOfBlock);
329 QString lineText = cursor.selectedText();
330 if (((oldPos - blockPos) > 0) && ((oldPos - blockPos) < int(lineText.length()))) {
331 bool isQuotedLine = false;
332 int bot = 0; // bot = begin of text after quote indicators
333 while (bot < lineText.length()) {
334 if ((lineText[bot] == QChar::fromLatin1('>')) || (lineText[bot] == QChar::fromLatin1('|'))) {
335 isQuotedLine = true;
336 ++bot;
337 } else if (lineText[bot].isSpace()) {
338 ++bot;
339 } else {
340 break;
341 }
342 }
343 evaluateListSupport(event);
344 // duplicate quote indicators of the previous line before the new
345 // line if the line actually contained text (apart from the quote
346 // indicators) and the cursor is behind the quote indicators
347 if (isQuotedLine && (bot != lineText.length()) && ((oldPos - blockPos) >= int(bot))) {
348 // The cursor position might have changed unpredictably if there was selected
349 // text which got replaced by a new line, so we query it again:
350 cursor.movePosition(QTextCursor::StartOfBlock);
352 QString newLine = cursor.selectedText();
353
354 // remove leading white space from the new line and instead
355 // add the quote indicators of the previous line
356 int leadingWhiteSpaceCount = 0;
357 while ((leadingWhiteSpaceCount < newLine.length()) && newLine[leadingWhiteSpaceCount].isSpace()) {
358 ++leadingWhiteSpaceCount;
359 }
360 newLine.replace(0, leadingWhiteSpaceCount, lineText.left(bot));
361 cursor.insertText(newLine);
362 // cursor.setPosition( cursor.position() + 2 );
363 cursor.movePosition(QTextCursor::StartOfBlock);
365 }
366 } else {
367 evaluateListSupport(event);
368 }
369 } else {
370 evaluateListSupport(event);
371 }
372}
373
374void RichTextComposer::evaluateListSupport(QKeyEvent *event)
375{
376 bool handled = false;
377 if (textCursor().currentList()) {
378 // handled is False if the key press event was not handled or not completely
379 // handled by the Helper class.
380 handled = d->composerControler->nestedListHelper()->handleBeforeKeyPressEvent(event);
381 }
382
383 // If a line was merged with previous (next) one, with different heading level,
384 // the style should also be adjusted accordingly (i.e. merged)
385 if ((event->key() == Qt::Key_Backspace && textCursor().atBlockStart()
386 && (textCursor().blockFormat().headingLevel() != textCursor().block().previous().blockFormat().headingLevel()))
387 || (event->key() == Qt::Key_Delete && textCursor().atBlockEnd()
388 && (textCursor().blockFormat().headingLevel() != textCursor().block().next().blockFormat().headingLevel()))) {
390 cursor.beginEditBlock();
391 if (event->key() == Qt::Key_Delete) {
392 cursor.deleteChar();
393 } else {
394 cursor.deletePreviousChar();
395 }
396 d->composerControler->setHeadingLevel(cursor.blockFormat().headingLevel());
397 cursor.endEditBlock();
398 handled = true;
399 }
400
401 if (!handled) {
403 }
404
405 // Match the behavior of office suites: newline after header switches to normal text
406 if ((event->key() == Qt::Key_Return) && (textCursor().blockFormat().headingLevel() > 0) && (textCursor().atBlockEnd())) {
407 // it should be undoable together with actual "return" keypress
409 d->composerControler->setHeadingLevel(0);
411 }
412
413 if (textCursor().currentList()) {
414 d->composerControler->nestedListHelper()->handleAfterKeyPressEvent(event);
415 }
417}
418
419bool RichTextComposer::processKeyEvent(QKeyEvent *e)
420{
421 if (d->externalComposer->useExternalEditor() && (e->key() != Qt::Key_Shift) && (e->key() != Qt::Key_Control) && (e->key() != Qt::Key_Meta)
422 && (e->key() != Qt::Key_CapsLock) && (e->key() != Qt::Key_NumLock) && (e->key() != Qt::Key_ScrollLock) && (e->key() != Qt::Key_Alt)
423 && (e->key() != Qt::Key_AltGr)) {
424 if (!d->externalComposer->isInProgress()) {
425 d->externalComposer->startExternalEditor();
426 }
427 return true;
428 }
429
430 if (e->key() == Qt::Key_Up && e->modifiers() != Qt::ShiftModifier && textCursor().block().position() == 0
431 && textCursor().block().layout()->lineForTextPosition(textCursor().position()).lineNumber() == 0) {
433 Q_EMIT focusUp();
434 } else if (e->key() == Qt::Key_Backtab && e->modifiers() == Qt::ShiftModifier) {
436 Q_EMIT focusUp();
437 } else {
438 if (!processModifyText(e)) {
439 evaluateReturnKeySupport(e);
440 }
441 }
442 return true;
443}
444
445bool RichTextComposer::processModifyText(QKeyEvent *event)
446{
447 Q_UNUSED(event)
448 return false;
449}
450
451void RichTextComposer::keyPressEvent(QKeyEvent *e)
452{
453 processKeyEvent(e);
454}
455
456Sonnet::SpellCheckDecorator *RichTextComposer::createSpellCheckDecorator()
457{
459}
460
461QString RichTextComposer::smartQuote(const QString &msg)
462{
463 return msg;
464}
465
466void RichTextComposer::setQuotePrefixName(const QString &quotePrefix)
467{
468 d->quotePrefix = quotePrefix;
469}
470
471QString RichTextComposer::quotePrefixName() const
472{
473 if (!d->quotePrefix.simplified().isEmpty()) {
474 return d->quotePrefix;
475 } else {
476 return QStringLiteral(">");
477 }
478}
479
480int RichTextComposer::quoteLength(const QString &line, bool oneQuote) const
481{
482 if (!d->quotePrefix.simplified().isEmpty()) {
483 if (line.startsWith(d->quotePrefix)) {
484 return d->quotePrefix.length();
485 } else {
486 return 0;
487 }
488 } else {
489 bool quoteFound = false;
490 int startOfText = -1;
491 const int lineLength(line.length());
492 for (int i = 0; i < lineLength; ++i) {
493 if (line[i] == QLatin1Char('>') || line[i] == QLatin1Char('|')) {
494 if (quoteFound && oneQuote) {
495 break;
496 }
497 quoteFound = true;
498 } else if (line[i] != QLatin1Char(' ')) {
499 startOfText = i;
500 break;
501 }
502 }
503 if (quoteFound) {
504 // We found a quote but it's just quote element => 1 => remove 1 char.
505 if (startOfText == -1) {
506 startOfText = 1;
507 }
508 return startOfText;
509 } else {
510 return 0;
511 }
512 }
513}
514
515void RichTextComposer::setCursorPositionFromStart(unsigned int pos)
516{
517 d->composerControler->setCursorPositionFromStart(pos);
518}
519
520bool RichTextComposer::isLineQuoted(const QString &line) const
521{
522 return quoteLength(line) > 0;
523}
524
525const QString RichTextComposer::defaultQuoteSign() const
526{
527 if (!d->quotePrefix.simplified().isEmpty()) {
528 return d->quotePrefix;
529 } else {
530 return QStringLiteral("> ");
531 }
532}
533
534void RichTextComposer::insertFromMimeData(const QMimeData *source)
535{
536 // Add an image if that is on the clipboard
537 if (textMode() == RichTextComposer::Rich && source->hasImage()) {
538 const auto image = qvariant_cast<QImage>(source->imageData());
539 QFileInfo fi;
540 d->composerControler->composerImages()->insertImage(image, fi);
541 return;
542 }
543
544 // Attempt to paste HTML contents into the text edit in plain text mode,
545 // prevent this and prevent plain text instead.
546 if (textMode() == RichTextComposer::Plain && source->hasHtml()) {
547 if (source->hasText()) {
548 insertPlainText(source->text());
549 return;
550 }
551 }
552
554 if (source->hasText()) {
555 const QString sourceText = source->text();
556 if (sourceText.startsWith("http://"_L1) || sourceText.startsWith("https://"_L1) || sourceText.startsWith("ftps://"_L1)
557 || sourceText.startsWith("ftp://"_L1) || sourceText.startsWith("mailto:"_L1) || sourceText.startsWith("smb://"_L1)
558 || sourceText.startsWith("file://"_L1) || sourceText.startsWith("webdavs://"_L1) || sourceText.startsWith("imaps://"_L1)
559 || sourceText.startsWith("sftp://"_L1) || sourceText.startsWith("fish://"_L1) || sourceText.startsWith("tel:"_L1)) {
560 insertHtml(QStringLiteral("<a href=\"%1\">%1</a>").arg(sourceText));
561 return;
562 }
563 }
564 }
565
567}
568
569bool RichTextComposer::canInsertFromMimeData(const QMimeData *source) const
570{
571 if (source->hasHtml() && textMode() == RichTextComposer::Rich) {
572 return true;
573 }
574
575 if (source->hasText()) {
576 return true;
577 }
578
579 if (textMode() == RichTextComposer::Rich && source->hasImage()) {
580 return true;
581 }
582
584}
585
586void RichTextComposer::mouseReleaseEvent(QMouseEvent *event)
587{
588 if (d->composerControler->painterActive()) {
589 d->composerControler->disablePainter();
590 d->richTextComposerActions->uncheckActionFormatPainter();
591 }
593}
594
595void RichTextComposer::slotTextModeChanged(KPIMTextEdit::RichTextComposer::Mode mode)
596{
597 d->composerControler->textModeChanged(mode);
598 d->richTextComposerActions->textModeChanged(mode);
599}
600
601#include "moc_richtextcomposer.cpp"
Instructs a builder object to create markup output.
The RichTextComposerActions class.
The RichTextComposerControler class.
The RichTextComposer class.
void enableWordWrap(int wrapColumn)
Enables word wrap.
void textModeChanged(KPIMTextEdit::RichTextComposer::Mode mode)
Emitted whenever the text mode is changed.
void disableWordWrap()
Disables word wrap.
void focusUp()
Emitted when the user uses the up arrow in the first line.
The RichTextExternalComposer class.
bool isValid(QStringView ifopt)
KGuiItem clear()
const QList< QKeySequence > & next()
virtual bool event(QEvent *event) override
virtual void keyPressEvent(QKeyEvent *e) override
virtual void mouseReleaseEvent(QMouseEvent *e) override
QChar fromLatin1(char c)
int key() const const
Qt::KeyboardModifiers modifiers() const const
bool hasHtml() const const
bool hasImage() const const
bool hasText() const const
QVariant imageData() const const
QString text() const const
Q_EMITQ_EMIT
bool disconnect(const QMetaObject::Connection &connection)
T qobject_cast(QObject *object)
void clear()
bool isEmpty() const const
QString left(qsizetype n) const const
qsizetype length() const const
QString & replace(QChar before, QChar after, Qt::CaseSensitivity cs)
bool startsWith(QChar c, Qt::CaseSensitivity cs) const const
bool mightBeRichText(const QString &text)
Key_Return
ShiftModifier
bool isValid() const const
QTextLayout * layout() const const
int lineCount() const const
QTextBlock next() const const
int position() const const
void clearSelection()
void endEditBlock()
void joinPreviousEditBlock()
QTextBlock begin() const const
void setAcceptRichText(bool accept)
virtual bool canInsertFromMimeData(const QMimeData *source) const const
void cursorPositionChanged()
void setHtml(const QString &text)
virtual void insertFromMimeData(const QMimeData *source)
void insertHtml(const QString &text)
void insertPlainText(const QString &text)
void setLineWrapColumnOrWidth(int w)
void setLineWrapMode(LineWrapMode mode)
void setOverwriteMode(bool overwrite)
void setPlainText(const QString &text)
void setTextCursor(const QTextCursor &cursor)
QTextCursor textCursor() const const
QString toPlainText() const const
void setWordWrapMode(QTextOption::WrapMode policy)
int textLength() const const
int textStart() const const
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
QLayout * layout() const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Mon Nov 18 2024 12:18:49 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.