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

KDE's Doxygen guidelines are available online.